Compare commits

...
114 Commits
Author SHA1 Message Date
av c1dab24de1 учёт: закрыта задача drop-dead-dotenv-loader, заведена drop-dead-entrypoint-script 2026-08-23 21:14:30 +03:00
av b01bdabc37 снят мёртвый godotenv.Load() вместе с зависимостью
- переменных окружения наш рабочий код больше не читает нигде: ушли вызов, импорт и предупреждение на старте
- у оставленной строки .env в .gitignore и у запрета forbidigo переписаны причины: прежние называли живым снесённого читателя
- в документы записано, что барьеры против .env стоят без читателя, и правило парности .gitignore с .dockerignore
2026-08-23 21:06:30 +03:00
av 1385dd3f5d раскладка av-dev поднята до версии 5: метка прогона ушла из процесса
- docs/review.md: подраздел «Триггеры метки» заменён на «Когда звать глубокое
  ревью» — областями кода и перечнем необратимых мест проекта
- дубли инвариантов и перечня необратимого сведены к ссылкам на CLAUDE.md, а в
  database.md убрано разошедшееся число мест правки колонки
- шапка раздела перестала называть самый поздний артефакт ревью: строка
  протухала каждым прогоном
2026-08-23 20:11:48 +03:00
av 404be2bdd5 storage: заголовок требования о выдаче файла говорит то же, что тело
- «Файл отдаётся ссылкой» осталось от снятого решения: тело требования прямо
  отрицает выдачу значения на предъявителя, право даёт узнавание при каждом
  обращении
- слово «ссылка» перестало нести в одном требовании три смысла: указатель в
  базе, значение доступа и путь к файлу в журнале
2026-08-23 16:58:30 +03:00
av 31ce520c5b документы: сведены расхождения, найденные сверкой канона
- один факт — один дом: рецепт локального входа, правило чтения
  X-Forwarded-For, уровень строки журнала и опись опор изъятия сведены к
  своим домам, копии заменены ссылками
- форма [auth.test_headers] выровнена по образцу конфига в семи местах;
  сценарии intake и archive перестали ссылаться на сессию, которой сервис
  не выдаёт
- поправлены протухшие факты: ключ объекта строит ULID, а не UUID; сверку
  адреса пира зовут трое, а не двое; обзор capability access знает о
  задаче 2026-08-23
2026-08-23 16:41:06 +03:00
av 11269c1567 учёт: заведён урожай ревью входа по конфигу
- пять задач партии review-2026-08-23: многозначность Remote-Name и Remote-Email,
  мелочи входа, механизация правила о зависимостях конфига, снятие мёртвого
  читателя .env, управляющие знаки в расширении
- две существующие разведки дополнены находками того же прогона: адрес объекта в
  тексте отказа SpeechKit и выводимость имени копии из журнала
2026-08-23 14:03:52 +03:00
av 0aa9b3a567 security.md: защита от многозначного заголовка описана точнее
Отбой двух значений закрывает только Remote-User; Remote-Email берётся первым
и закрепляется за чужой учётной записью навсегда. Путь построен ревью
2026-08-23, правило распространяется на всю тройку отдельной задачей.
2026-08-23 13:41:53 +03:00
av 52fe31319a локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
2026-08-23 13:12:47 +03:00
av 75c6f0168a учёт: закрыт переезд хранилища, заведён урожай его ревью
- storage-without-pocketbase закрыта как реализованная: приёмка сошлась по всем
  пяти критериям записи и по двенадцати приёмочным свойствам рубрики ревью
  дизайна, работа лежит коммитом c9b7765
- урожай триажа ревью развёрнут в одиннадцать записей с тегом партии
  review-2026-08-23 и расставлен по зависимости, а не в конец списка
- находка про признак живости воркера слита в stalled-pipeline-metric: у неё та
  же причина — вставший конвейер неотличим от простоя
2026-08-23 08:41:49 +03:00
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00
av 1edf8cb225 записан подход к проектированию: чистая архитектура и тактические шаблоны DDD
- architecture.md: раздел «Слои и модель домена» — таблица слоёв с колонкой «чего не знает», шаблоны со своим сегодняшним предметом в коде, запрет анемичной модели
- CLAUDE.md: раздел «Проектирование» с подходом и ссылкой на раздел архитектуры
- названо, что чистоту домена не держит ни одно правило archrules
2026-08-22 21:05:13 +03:00
av ad5b5e377f записано решение уйти с PocketBase на SQLite со своим каталогом файлов
- разведка storage-without-pocketbase: шесть ролей библиотеки в этом коде, отпавший довод перевода, шесть модулей достижимы только через неё
- ADR-2026-08-22-storage-without-pocketbase заменяет три записи: перевод в PocketBase, очередь коллекцией, файл за защищённым полем
- задача storage-without-pocketbase встала первой строкой плана стройки
2026-08-22 21:00:14 +03:00
av a8fb4793be закрыта задача trusted-header-login
- две записи, ссылавшиеся на убранный oidc-login, переписаны
- dev-run-task переведена с отдельного cmd/devadmin на подкоманду cmd/devtools
2026-08-22 20:25:13 +03:00
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00
av e4441f3c49 tasks: вход переезжает на заголовки прокси, пять задач про OIDC закрыты
- заведена `trusted-header-login` и поставлена в голову очереди
- пять задач про механику OIDC закрыты как отменённые ею
- `dev-run-task` и `api-tokens` переписаны под новый вход
2026-08-22 17:43:18 +03:00
av 2bb252e018 tasks: владельца панели заводит отдельная команда, а не ключ конфига
- admin-owner-from-config закрыта: решение владельца 2026-08-15, ключ конфига
  не заводится, требование спеки storage остаётся в силе
- dev-run-task вобрала cmd/devadmin и стоит первой строкой: инструмент и шаг
  порознь не нужны, поэтому едут одной задачей
2026-08-15 20:49:50 +03:00
av 97c6c7c440 tasks: заведены задачи про владельца панели из конфига и команду локального запуска
- admin-owner-from-config первой строкой: сервис заводит владельца панели
  по ключу конфига; задача отменяет требование спеки storage, и это названо
  в рамках
- dev-run-task следом: task dev поднимает сервис и заглушку разом, гасит
  оба по Ctrl+C; зависит от ключа конфига
2026-08-15 20:39:45 +03:00
av daa4c3b6e4 oidcstub: записано решение не заводить заглушке своих тестов
- решение владельца 2026-08-15: пакет в образ не едет, отказ громкий,
  а путь входа проверен подставным провайдером в тестах транспорта
2026-08-15 20:18:58 +03:00
av 91138dd39a точки входа переехали в cmd/, заведена заглушка OIDC для локального входа
- main.go и journal_route_test.go переехали в cmd/transcriber без правок
  содержимого; образ собирает ./cmd/transcriber поимённо
- cmd/oidcstub отвечает на /authorize, /token и /userinfo, проверок не делает
  и слушает петлевой адрес: войти без Authelia стало чем
- ступень сборки приложения переехала на node:24 с alpine — musl ждёт ответа
  на AAAA, которого нет, и npm ci висел вместо отказа
2026-08-15 20:15:57 +03:00
av 63a13404df заведена задача про сканер уязвимостей в зависимостях приложения 2026-08-15 19:02:36 +03:00
av 8c18abc24e закрыта задача spa-skeleton 2026-08-15 18:53:24 +03:00
av 663021f712 приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное
  вшивается через go:embed и раздаётся корневым маршрутом: разметка на
  неизвестном пути вне корней сервиса, отказ контракта внутри корня
- перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов,
  а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь
- шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка
  входят в гейт, а в Dockerfile появилась ступень приложения
2026-08-15 18:51:05 +03:00
av c6ffda9aac web-ui.md: ссылка на закрытую задачу заменена ссылкой на спеку archive 2026-08-15 13:54:12 +03:00
av a5bc322814 закрыта задача json-api-for-spa 2026-08-15 13:52:06 +03:00
av 3a2da3004b приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
2026-08-15 13:51:23 +03:00
av 79ff12548f tasks: спроектирован контракт приложения и переставлена голова очереди
- json-api-for-spa: адреса приложения уехали в своё пространство /app/,
  приём стал POST /app/audiorecords, опрос /api/status/:id убран, заведены
  список, карточка, текст, /app/me и /app/config; имя файла отправителя
  легло своей колонкой рядом с заголовком
- три действия над записью — правка заголовка, возврат в работу и журнал
  событий — собраны задачей audiorecord-actions
- голова очереди: контракт, каркас, экран загрузки, список, действия
- в прежних задачах поправлены адреса, рубежи конвейера и остатки Telegram
2026-08-15 09:06:11 +03:00
av d88e56efcb claude.md: объявлена стадия стройки и выкладка с чистого листа
- на сервере данных нет и сервис остановлен, совместимость с ним не требуется
- запрет на боевой каталог и на переписанный шаг схемы этим не снимается
2026-08-15 08:12:18 +03:00
av b4b19db6e4 tasks: заведён урожай ревью remove-telegram-intake
- шесть записей по кластерам причин: журнал под внешним значением, нулевой
  код ответа в журнале, затирание вложения бедным ответом, открытый анониму
  адрес подтверждения почты, рубеж расшифровки без работы, пределы длительности
- находка про код 500 у отказа приёма дописана в json-api-for-spa: там живёт
  единая точка отображения доменной ошибки
- telegram-account-link и bot-api-only-through-bot-client оставлены с оговоркой,
  что предмета у них нет до возвращения входа
2026-08-15 07:46:16 +03:00
av 8f7c3a057a удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
2026-08-15 07:24:35 +03:00
av 97ceb7bb69 заведена задача failure-verdict-vs-retry 2026-08-14 20:38:12 +03:00
av 9a964f2efc закрыта задача record-centric-model 2026-08-14 20:20:58 +03:00
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00
av d079f03350 заведена задача на перестройку модели вокруг аудиозаписи
- центральная сущность — аудиозапись: файлы, тексты, структура реплик и темы
  живут отдельными строками, поля очереди перестают соседствовать с содержимым,
  а провайдерское уезжает в свою таблицу
- конвейер становится цепочкой рубежей с остановкой признаком: рубеж не
  стирается, и запись перезапускается с места остановки
- воркеры теряют специализацию, их число задаётся конфигом
2026-08-14 16:46:40 +03:00
av a67cdee382 закрыта задача record-ownership 2026-08-14 12:19:19 +03:00
av 8af8ec2e54 у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
2026-08-14 12:18:11 +03:00
av b7d4660aef канон: раскладка повышена до версии 4
- слово «провенанс» снято из словаря проектных текстов: у числа теперь
  «происхождение», у вопроса и находки — «откуда»
- правлены форма вопроса в docs/review.md, шапка раздела об OIDC в
  docs/research/pocketbase.md и две записи задач; формулировки прошли
  вычитку агентами doc-wording и task-wording
- архив openspec/changes/archive/ не тронут: слово, верное на день записи,
  остаётся свидетельством
2026-08-14 09:51:01 +03:00
av 62b2829cba go: тулчейн поднят до 1.26.6
- директива `go` в go.mod — единственное место, где назван патч: шаг гейта
  сверяет мажор и минор, поэтому `golang:1.26-alpine` в Dockerfile и «Go 1.26»
  в CLAUDE.md и README.md остались верными
- директива toolchain не заведена: она стала бы пятым местом с версией и
  уронила бы сверку
- достижимых из кода уязвимостей у govulncheck больше нет; недостижимая
  GO-2026-5932 в golang.org/x/crypto/openpgp остаётся объявленной
2026-08-14 09:50:50 +03:00
av e660617ba0 закрыта задача config-example-toml 2026-08-14 09:32:24 +03:00
av ce0ae76977 config: образец переименован в config.example.toml
- имя приведено к конвенции, которая сама называла его расхождением;
  ссылки поправлены в README, CLAUDE.md, конвенциях, docs/review.md и двух
  записях задач
- в шапке docs/conventions/config.md заодно поправлен второй пункт перечня:
  проверки на старте у `[auth]` и `[telegram]` уже есть
- архив openspec/changes/archive/ не тронут: это запись о прошлом
2026-08-14 09:32:03 +03:00
av 312caf0fa3 tasks: тип login-url-from-collection-settings сменён на feature
- задача оказалась шире chore: у нормированного адреса входа появляется
  новый исход отказа, а обязательность проверочного кода PKCE переезжает
  в настройки провайдера в хранилище
- дальше она идёт сценарием решения, следующим прогоном
2026-08-14 08:45:44 +03:00
av cd57b68215 config: включение Telegram разведено с ключом доступа
- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет,
  файл без него негоден; bot_token стал только ключом доступа и при
  enabled = false не читается вовсе, а пустой при enabled = true роняет старт
- выключенный вход даёт подъём одним входом без единого обращения к Telegram и
  записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение
- отказ разбора файла настроек больше не пересказывает toml — её ParseError
  несёт в тексте разбираемое значение, и оборванная строка секретного ключа
  уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
2026-08-13 21:46:14 +03:00
av 903941f587 docs: точного числа накопленного в документах больше нет
- CLAUDE.md, «Язык»: ссылаться можно на конкретную запись или на весь корпус
  разом, но не на их количество — число протухает молча, машина его не считает.
  Изъятие названо: неизменное число и историческое в записи о прошлом остаются.
- Сняты счёты capability, прогонов ревью, типизированных ошибок, воркеров,
  сверок документов и правил линтера в docs/, спеке pipeline и CLAUDE.md.
- Заодно исправлено то, что этот же счёт и скрывал: типизированных ошибок три,
  а не две — LostAcquisitionError был потерян из перечня.
2026-08-13 19:23:00 +03:00
av 4c87220d90 docs: поправлен маркер канона и записано правило о живом прогоне
- architecture.md: маркер над таблицей компонентов ссылался на
  несуществующую capability delivery; теперь на intake, pipeline и storage,
  и названо непереехавшее — приём из Telegram и деление текста по словам.
- review.md: проход, поднявший сервис, обязан его остановить, а меряющий —
  убедиться, что отвечает его сборка. Цена правила уже заплачена: оставленный
  процесс держал порт, и замеры ушли к прежней сборке.
2026-08-13 19:15:57 +03:00
av edcf8ede70 закрыта задача local-run-without-telegram-token 2026-08-13 19:10:28 +03:00
av b733a84d6a telegram: сервис поднимается без бота и работает одним входом
- Клиент бота собирается один раз и достаётся отправителю и транспорту;
  разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет
  старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание
  при сборке ограничено сроком — иначе молчащий Telegram вешал подъём.
- Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой,
  уровень по причине — WARN для неподнятого входа, ERROR для неназванного
  адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count.
- Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше
  обращения к клиенту, то есть мимо чистки на его границе.
2026-08-13 19:10:08 +03:00
av 863ba3b42e tasks: local-run-without-telegram-token переведена в feature
- Тип сменён с chore: подъём без токена бота меняет наблюдаемое поведение и
  требует нормы, которой в openspec/specs нет.
- В тело записан второй предмет работы — судьба задачи из Telegram, дошедшей до
  ответа при отсутствующем боте; он же стал четвёртым критерием приёмки.
2026-08-13 16:59:44 +03:00
av 220a4374b1 docs: раздел линтеров назван «Код проверок и подавления»
- Прежнее имя «Проверки о самих проверках» читалось против запрета в CLAUDE.md,
  хотя правила в нём — линтеры над кодом проверок, то есть тот же один уровень.
2026-08-13 16:52:52 +03:00
av ec136b50fb scripts: снесены проверки шага сверки версий Go
- Двадцать сценариев шага были единственной проверкой над проверкой в проекте;
  запрет CLAUDE.md остался без исключений.
- Ссылки на файл сняты в памятке, конвенции линтеров, журнале ревью и статусе
  ADR о спеке toolchain; норма шага живёт комментариями в самом скрипте.
2026-08-13 16:51:15 +03:00
av 54268b5933 CLAUDE.md: запрет на проверки над проверками
- Уровень проверки один: линтеры и тесты судят код сервиса, судить их самих
  проект не берётся; названы попавшие под запрет виды работ.
- Пересказ решения в go-linters.md и review.md заменён ссылкой на дом.
2026-08-13 16:47:54 +03:00
av 539ed926cb docs: сняты проверки над проверками
- Из «Любой узел» в review.md убраны три свойства о годности самих проверок:
  мутация теста, мутация оракула критерия, требование без сценария.
- Из go-linters.md снята «Лестница механизации», ссылки на неё переписаны
  в конвенциях, их индексе и журнале дефектов.
2026-08-13 16:45:12 +03:00
av cb65967389 tasks: закрыта задача rollback-does-not-undo-schema-step
- Отменена владельцем: работа целиком документационная, факт об откате
  и применённом шаге схемы остаётся неназванным.
2026-08-13 16:40:09 +03:00
av 26256cdb06 openspec: упразднена спека toolchain
- Инструментарий проекта спеками не нормируется: capability toolchain удалена,
  норму шага сверки версий Go держат его проверки в scripts.
- Перечень capability в архитектуре и ревью сокращён до четырёх, решение
  ADR-2026-08-12-spec-norms-build-toolchain помечено устаревшим.
2026-08-13 16:36:22 +03:00
av 6994feec55 tasks: закрыты три задачи о проверках над проверками
- Отменены migrations-step-norm-and-tests, gate-steps-subject-guard и
  review-config-from-go-upgrade: много механики, мало пользы.
- go-linters.md больше не числит отсутствие проверок шага migrations долгом —
  это решение, а не незакрытая работа.
2026-08-13 16:31:39 +03:00
av 3ffb5109a7 tasks: закрыта задача gate-changed-lines-coverage
- Владелец отменил механизацию покрытия изменённых функций 2026-08-13.
- Причина и дата уехали в REJECTED.md; в коде и документах ничего не менялось.
2026-08-13 16:29:00 +03:00
av f7a8a1df9d tasks: восемь записей интейка получили место в плане стройки
- migrations-step-norm-and-tests, gate-steps-subject-guard и
  review-config-from-go-upgrade уехали в голову: слой проверок, которым верят
  все задачи ниже;
- rollback-restores-wrong-session-duration встал рядом с соседом про откат,
  bot-api-only-through-bot-client — рядом с чисткой транспорта,
  pin-runtime-image-base — перед spa-skeleton, откуда начинаются пересборки;
- pipeline-spec-purpose-drift закрыта реализованной, а
  docs-consistency-2026-08-13 переписана под пять оставшихся находок: шестую
  свело повышение раскладки.
2026-08-13 16:11:58 +03:00
av 2c12376262 docs: ссылки на упразднённый роадмап переадресованы, у восьми фактов назван дом
- ссылки на tasks/ROADMAP.md переведены на BACKLOG.md и на openspec/specs,
  упоминания целей — на задачи, которые эту работу делают;
- судьи документации нашли восемь расхождений: Purpose спеки pipeline объявлял
  неописанным то, что уже нормирован пятью требованиями, вид времени в
  конвенции спорил со схемой, а квоты, шесть часов и отказ от Web Push жили
  сразу в двух документах без ссылки друг на друга;
- два числа получили провенанс: 259 200 запросов в сутки и потолок в шесть
  часов теперь ведут к записке разведки, а не читаются как замер.
2026-08-13 16:00:31 +03:00
av 5501384cdc tasks: упразднены цели и роадмап, объявлена стадия build
- 11 записей типа goal закрыты с причиной, называющей задачи-наследники;
  ROADMAP.md удалён, индекс остался один — BACKLOG.md;
- 33 записи переписаны: ссылка «Двигает пункты N «Завершения» цели» уступила
  место прямому утверждению — без целей номера пунктов вели в никуда;
- шапка BACKLOG.md размечена парой <!-- стадия -->, порядок строк теперь
  объявлен зависимостью, а не важностью.
2026-08-13 16:00:14 +03:00
av 00148bcfb5 Taskfile.yml: поправлен путь к docs.py после переименования скилла
Каталог скилла зовётся skills/canon, а переменная вела в skills/doc-canon:
шаг гейта скрипт не находил и краснел кодом 3, что читалось как отсутствие
плагина, — раскладку документов при этом не проверял никто.
2026-08-13 15:59:57 +03:00
av 32949e7b01 chore: список включённых плагинов Claude Code уехал в репозиторий
- .claude/settings.json объявляет av-dev и av-dev-git включёнными,
  так что скиллы канона и коммитов поднимаются вместе с проектом
2026-08-13 12:40:24 +03:00
av b76f2d7c7e docs: раскладка переехала в .av-dev.toml, а расхождения документов сведены
- Перевод на канон 1 доделан: адреса служебного файла и имена скиллов
  переставлены в девяти местах прозы и кода, гейт зовёт три скрипта по новым
  путям, прежние docs/.docs.json и tasks/.tasks.json удалены.
- Сверка двумя агентами нашла четырнадцать расхождений, тринадцать сведены
  строками: число прогонов ревью и преамбула журнала дефектов, счёт capability,
  маршруты README, дубли инварианта захвата и кодов прогона, протухшие указатели
  записок разведки, маркер долга на переехавшем абзаце. Срок жизни сессии
  нормирует спека access, database.md на неё ссылается.
- Purpose спеки pipeline объявляет неописанным то, что в ней же и стоит; правка
  идёт изменением openspec, поэтому заведена задача pipeline-spec-purpose-drift.
2026-08-13 12:36:36 +03:00
av eacaf76d5f tasks: заведён остаток работы о гейте, контексте и токене
- четыре новые записи: проверить шаг migrations так же, как шаг сверки версий Go;
  свести шесть расхождений между документами канона; запретить обращаться к Bot
  API мимо клиента бота; разведка о шагах гейта, теряющих предмет
- context-cancel-in-pipeline приведена к правде: дописан перечень сделанного
  попутно, критерий с оракулом «тест на трёх прерываниях подряд» разбит надвое —
  проверена была только его узкая половина
2026-08-13 10:55:38 +03:00
av f4d8c7ed50 docs: мутация, не собравшаяся, — не мутация
- порядок заведения правила пополнен строкой: правка, снявшая последнее
  употребление импорта, роняет сборку, а не проверку, и её вывод легко принять
  за сработавшую мутацию. Случай был в этом же сеансе — проба приёма по HTTP
2026-08-13 10:29:17 +03:00
av bb9a67929c docs: правила о контексте и о токене записаны, две находки — в журнал
- в go-linters.md заведён раздел «Отмена и внешний собеседник», перечень
  механизированного пополнен девятью правилами и двумя шагами гейта, названы
  остатки: contextcheck не видит сигнатуру без контекста вовсе, шаг migrations
  судит только шаги, бывшие в базе диффа, rowserrcheck и sqlclosecheck
  профилактические — предмета в коде нет
- в logging.md и security.md чистка отказа Telegram описана по факту: точка одна
  и лежит на границе клиента, закрыты все пять путей вместе с логгером самой
  библиотеки. Прежнее «*Расхождение:* вычистки нет... она не логируется» было
  неверным дважды
- в журнал дефектов записаны две находки: отказ скачивания уносил токен бота
  (проскочил, жил с самого начала) и остановка сервиса хоронила конвертируемую
  запись в failed (поймано ревью до коммита)
- вопрос темы operations про отмену переформулирован: спрашивать надо не
  «доходит ли контекст», а «что шаг делает с задачей, деньгами и ответом
  отправителю»; вопрос про таймаут оставлен с оговоркой, что проброс контекста
  на него не отвечает
- в памятке: словарь кодов новых шагов, требование компилятора C у детектора
  гонок и оговорка, что «CGO не нужен» относится к сборке, а не к гейту
2026-08-13 10:28:31 +03:00
av bc5c35790e Гейт ищет гонки, переписанные шаги схемы и девять новых классов дефектов
- включены noctx, contextcheck и bodyclose (отмена доходит до внешнего вызова,
  контекст приезжает сверху, тело ответа закрывается), nilerr, rowserrcheck и
  sqlclosecheck (отказ не теряется молча), testifylint и nolintlint (форма
  утверждения и форма подавления), плюс errcheck check-type-assertions:
  непроверенное приведение типа паникует, и check-blank его не видит
- заведён шаг tests: go test -race, потому что «результат пишет только держатель
  захвата» — утверждение об одновременности. Без компилятора C шаг гоняет тесты
  без детектора и краснеет кодом 3 после них: гонки не повод отнимать у гейта
  сами тесты
- заведён шаг migrations: у файла шага схемы допустим один статус — A. Баз диффа
  две, BASE и HEAD: первая отвечает на «уже уехал» настолько, насколько свежа
  origin/master, вторая ловит правку закоммиченного шага независимо от неё.
  Каталог берётся из docs/.docs.json, пустой каталог роняет шаг
- единственное подавление — noctx на httptest.NewRequest в проверках: за
  фикстурой запроса внешнего собеседника нет. Граница проверена мутацией —
  http.Get и exec.Command из проверки правилу по-прежнему подсудны
2026-08-13 10:28:12 +03:00
av f494dcb83e Отмена доходит до внешнего собеседника, а токен не покидает единой точки
- контекст проложен от воркера и обоих входов до внешних вызовов: ffmpeg и
  ffprobe заводятся через exec.CommandContext, SpeechKit и Object Storage
  принимают ctx вместо context.Background, скачивание записи идёт запросом с
  контекстом. Прежде остановка сервиса не доходила до чужой работы вовсе
- прерванный шаг приговора не выносит: убитый по контексту ffmpeg отдаёт
  «signal: killed», от настоящего отказа неотличимо ни типом, ни errors.Is, и
  различает их только ctx.Err(). Задача остаётся на повтор, попытку не тратит и
  отправителю о несуществующем сбое не сообщает; воркер не считает остановку
  отказом, а задача не забирается вовсе, если нас уже остановили
- клиента Bot API заводит единая точка internal/adapter/telegram: токен стоит в
  пути каждого обращения, а http.Client кладёт адрес в *url.Error целиком.
  Чистка на месте употребления закрывала один вызов из пяти — теперь свой Do
  чистит отказ, подменённый логгер вычищает токен из строк самой библиотеки, а
  транспорт бота токена не получает вовсе
- принятие операции распознавания защищено от отмены своим пределом: SpeechKit
  мог её принять и начать считать деньги, а потерянный идентификатор заставил
  бы повтор оплатить ту же запись второй раз
- приём по HTTP доводит запись до задачи независимо от отправителя: на
  контексте запроса один обрыв соединения терял полностью загруженную запись
- ответ Telegram с не-2xx кодом больше не становится записью: прежде тело
  отказа доезжало до хранилища и умирало на ffprobe, уводя диагностику
2026-08-13 10:27:54 +03:00
av d8d6bcc193 docs: линтеры и механизация переехали записью конвенций go-linters.md
- документ docs/autotests.md снят: перечень правил, подавлений и лестница
  механизации — это конвенция о том, чем машина читает код, и место ей среди
  прочих записей
- в шапке названо, чего в записи нет: как писать тесты. Свойства, которые обязан
  проверять тест, остаются в review.md, «Типовые узлы»
- ссылки переставлены в памятке, индексе конвенций, четырёх записях, review.md,
  .golangci.yml, lefthook.yml и пакете сканеров
2026-08-13 09:11:49 +03:00
av 8bcd2c0059 tasks: закрыта задача gate-extra-linters
- shellcheck, hadolint и мутационный тест скрипта сверки версий заведены, набор
  предкоммитных проверок назван в памятке
2026-08-13 09:02:31 +03:00
av 37ccda3677 docs: autotests.md стал переносимым документом об автопроверках
- лестница механизации из пяти ступеней, два круга проверок, перечень правил
  таблицами по родам, перечень подавлений с причинами, порядок заведения
  правила; названы остатки правил и отклонённые подъёмы
- утверждения «Механизировано» в четырёх записях конвенций и единые точки в
  architecture.md приведены к сегодняшнему состоянию; изъятие «транспорт знает
  адаптер хранилища» названо строкой
- в журнал дефектов записаны две находки: узнавание конца потока по тексту и
  правило гейта, обходимое одной лишней строкой
2026-08-13 09:02:19 +03:00
av e1dfe662ea Гейт видит скрипты и Dockerfile, а pre-commit — затронутые файлы
- шаги shell (shellcheck) и dockerfile (hadolint) заведены; два правила hadolint
  подавлены поимённо с причиной — DL3007 до задачи pin-runtime-image-base и
  DL3018 по существу
- lefthook гоняет на затронутых файлах gofmt, golangci-lint по их каталогам,
  shellcheck, hadolint и gitleaks — около секунды; полный набор в pre-commit не
  переносится намеренно
- заведён мутационный тест скрипта сверки версий: 20 сценариев спеки toolchain
  плюс требование сообщения называть все четыре места, независимость исхода от
  установленного go и запрет звать go, docker и сеть
2026-08-13 09:02:05 +03:00
av 0353517ec4 Линтеры и сканеры взяли на себя то, что было прозой конвенций
- включены sloglint, misspell, depguard; forbidigo получил запреты на чтение
  времени, окружения и вывод в stdout — каждый по свойству, а не по одному
  имени: запрет на os.Getenv без соседей обходится os.LookupEnv
- заведён internal/archrules — тесты-сканеры: направление зависимостей между
  ядром, транспортами и адаптерами, узнавание ошибки по тексту в шести формах,
  и согласованность колонок очереди во всех четырёх местах плюс шаг схемы
- каждое правило проверено мутацией, каждое исключение объявлено с причиной
2026-08-13 09:01:52 +03:00
av 1d243ad2f6 Время читается единой точкой, а конец потока узнаётся не по тексту
- заведён internal/clock: Now даёт метку в UTC, Start — начало измерения
  длительности с монотонными часами; девять мест рабочего кода и запрос захвата
  переведены на него, долг «время time.Now() по месту» закрыт
- клиент SpeechKit узнавал конец потока сравнением err.Error() == "EOF": отказ
  с тем же текстом вернул бы усечённую расшифровку как готовую, теперь
  errors.Is(err, io.EOF)
- починены находки новых линтеров: две опечатки, два slog.DiscardHandler,
  четыре неэкранированные подстановки в docker/entrypoint.sh
2026-08-13 09:01:36 +03:00
av 6c7f006e75 docs: инструменты гейта переехали в свой дом — docs/autotests.md
- перечень «Механизировано» и то, что осталось прозой, снято из конвенций: они
  про то, как писать код, а не про инструменты, которые его читают
- новый документ — дом темы ревью autotests, с границами: семантика гейта
  остаётся в CLAUDE.md, журнал дефектов и вопросы по темам — в review.md
- вопрос ревью о суждении по готовому ответу сужен до того, что машина не
  проверяет: до ответа мимо recorder
2026-08-13 07:35:58 +03:00
av 8bffd30955 tasks: закрыты четыре задачи о гейте, заведён урожай ревью
- сняты fix-migrations-path-in-docs-config, gate-step-exit-codes,
  response-assertions-judge-result, gate-dependency-vulnerabilities
- заведена rollback-restores-wrong-session-duration: откат шага входа ставит
  14 суток, называя их умолчанием библиотеки в пять
2026-08-12 22:06:11 +03:00
av 3925c637f3 Гейт ловит достижимые уязвимости в зависимостях
- шаг vulns гоняет govulncheck последним: ему одному нужна сеть, и он самый
  долгий; отсутствие инструмента даёт код окружения, а не пропуск
- закрыты обе достижимые находки — grpc до 1.82.1, aws-sdk-go-v2/service/s3 до
  1.97.3 с eventstream 1.7.8; прогон на реальных ключах Yandex не делался
- место шага названо в семантике гейта: он судит достижимость из кода, и
  недостижимая GO-2026-5932 в golang.org/x/crypto/openpgp его не роняет
2026-08-12 22:04:53 +03:00
av 35bde75b1f Гейт роняет проверку, судящую ответ по живой карте заголовков
- forbidigo с analyze-types запрещает в файлах проверок обращение к
  httptest.ResponseRecorder.Header и .HeaderMap: правило судит по типу
  приёмника, поэтому ловит и цепочку, и переменную, и индекс, и обход
- класс стоил трёх зелёных гейтов при неработающем коде; правило записано
  строкой в перечне механизированного, прозой не дублируется
- поправлены два утверждения «механизировано: ничего», разошедшиеся с
  включённым линтером и со сверкой шага схемы
2026-08-12 22:04:35 +03:00
av 870b6bc829 Обёртки шагов гейта отдают код окружения, а не код дрейфа
- недостающий скрипт у всех четырёх обёрток в Taskfile.yml даёт 3, как обещает
  словарь: прежний 1 значил «дрейф» и отправлял искать разъехавшееся там, где
  просто неполно дерево
- в словарь кодов CLAUDE.md добавлены сами обёртки и оговорка про 201: этим
  кодом task отдаёт наружу любой отказ шага, а код шага печатает строкой
2026-08-12 22:04:06 +03:00
av 4a052ec99b Шаги схемы вынесены в свой каталог, и сверка их снова видит
- шаги PocketBase переехали из файла в пакет
  internal/adapter/repo/pocketbase/migrations, файл на шаг с именем
  зарегистрированного шага; туда же имена коллекций, срок сессии — в provider.go
- ключ migrations в docs/.docs.json наведён на этот каталог: прежнее значение
  указывало на несуществующий migrations/, и шаг гейта проходил зелёным при
  всякой правке схемы
- app.go подключает пакет шагов явным пустым импортом: пропавшая ссылка на
  константы унесла бы регистрацию, и хранилище поднялось бы без коллекций
2026-08-12 22:03:48 +03:00
av b46be019fc docs: канон приведён к сегодняшнему состоянию после сверки
- периметр: passport.md и security.md больше не утверждают, что HTTP API
  открыт без аутентификации, а review.md не числит эту находку типовой
  ложноположительной — приём, опрос и файл закрыты сессией с 2026-08-12;
- logging.md писал, что расширение попадает в журнал полем пути: описано
  изъятие инварианта приватности — собственное поле, имени и пути нет;
- узел ревью переименован в repo/pocketbase, поведение конвейера из обзора
  уехало ссылкой в спеку pipeline, Purpose спеки storage написан вместо
  заглушки, в ADR о переезде дописано уточнение о действующей раскладке.
2026-08-12 21:13:06 +03:00
av 8739b18a9f tasks: очередь расставлена от базы к деталям
- порядок беклога и роадмапа назначен слоями: проверки, которым можно
  верить → долги входа → владелец записи и контракт API → конвейер под
  тестами → приложение и возможности поверх; у каждого движения записана
  причина;
- заведены восемь задач под пункты «Завершения», которых не закрывала ни
  одна запись, — цель any-audio-source была без задач вовсе;
- у четырёх задач сняты критерии, требовавшие того, что делает задача ниже
  по очереди; исправлены ссылки на несуществующий repo/sqlite и на
  отменённую разведку об очереди.
2026-08-12 20:48:53 +03:00
av ddc34b3182 tasks: закрыта задача oidc-login, заведён урожай ревью 2026-08-12 18:10:59 +03:00
av c44f0e7582 HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой:
  собственную регистрацию, вход по паролю и одноразовый код — без этого
  закрытие приёма обходилось двумя запросами
- продление сессии выключено, срок семь суток: иначе отзыв доступа у
  провайдера до сервиса не доходит никогда
- файл записи отдаётся вошедшему по токену файла — пересмотр
  ADR-2026-08-12-file-link-open-but-not-logged
2026-08-12 17:44:22 +03:00
av d676df8a27 tasks: закрыта задача go-1-26-upgrade, заведён урожай ревью
- четыре задачи из находок ревью и заметки владельца: проверки shellcheck и
  hadolint с тестом скрипта сверки версий, закрепление рантайм-базы образа,
  коды выхода шагов гейта, настройка конвейера ревью
- INBOX разобран целиком и очищен
2026-08-12 14:26:07 +03:00
av 09228f23d8 Go обновлён до 1.26, а расхождение версий теперь роняет гейт
- шаг go-version в task gate сверяет объявленную версию в go.mod, Dockerfile,
  CLAUDE.md и README.md; судит по репозиторию, go не зовёт, docker и сети не
  требует
- заведена capability toolchain: до сих пор спеки нормировали только поведение
  сервиса, теперь и инструмент сборки. Причина и цена — в двух ADR
- закрыт дефект 2026-08-12: образ на golang:1.24-alpine разошёлся с go.mod и
  перестал собираться, а восемь шагов гейта и шесть проходов ревью были зелёными
2026-08-12 10:57:50 +03:00
av aa20b229f9 tasks: заведена задача про прослушивание записи в приложении
- play-recording-in-app: экран, привязка проигрываемой копии к задаче,
  отдача файла хранилищем
- цели web-access добавлен седьмой пункт «Завершения»
2026-08-12 09:14:05 +03:00
av f48110df5a tasks: обновление Go и сверка версии собраны в одну задачу
- go-1-26-upgrade вобрал критерии gate-go-version-sync: версия правится и
  сверяется одним заходом
- gate-go-version-sync ушла в REJECTED с причиной
2026-08-12 09:09:12 +03:00
av 07433bb9f9 заведены задачи из урожая ревью и заметок владельца 2026-08-12 09:01:31 +03:00
av 5f1273292a закрыта задача pocketbase-storage 2026-08-12 08:32:25 +03:00
av 01cc31d45f хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
2026-08-12 08:31:59 +03:00
av 09cedc4e61 закрыта задача errors-as-instead-of-typecast 2026-08-11 18:05:07 +03:00
av 2559d09fc8 доменные ошибки сравниваются через errors.As, отказ Close не теряется
- признаки «работы нет» и «задача не найдена» узнаются по смыслу, а не
  приведением типа: обёртка `%w` на пути больше не превращает пустой прогон
  воркера в отказ раз в секунду
- отказ закрытия соединения с распознавателем доходит до вызывающего
  (`errors.Join`) либо до журнала; у `errcheck` включён `check-blank`, иначе
  критерий принимал реализацию, выбрасывающую отказ в пустоту
- заведены первые тесты пакета worker и capability `pipeline`; долг из четырёх
  замечаний линтера закрыт, гейт зелёный целиком
2026-08-11 18:04:25 +03:00
av b7dd060ba0 CLAUDE.md: в инвариант приватности дописано изъятие про расширение
- хвост после последней точки попадает в журнал внутри пути файла и остаётся
  там осознанно; наружу он выходит только приведённым к перечню
- в модели угроз это перестало читаться незакрытым остатком
2026-08-11 16:42:10 +03:00
av 614771139a закрыта задача no-user-filename-in-log 2026-08-11 16:38:17 +03:00
av bd6001cd8d имя файла отправителя убрано из журнала приёма
- расширение приводится к перечню известных форматов прежде метки метрики:
  страница метрик открыта, и хвост имени уезжал на неё дословно
- проверки приёма перехватывают все три потока журнала и читают реестр метрик,
  каждая падает при снятии того, что сторожит
2026-08-11 16:37:58 +03:00
av ffa36e96c7 закрыта задача spa-framework-choice 2026-08-11 14:52:04 +03:00
av 54ca4c0e50 docs: фреймворком приложения выбран Vue 3 с роутером 5 и сборкой Vite
- заведены записка разведки docs/research/spa-framework.md со сравнением Svelte,
  Vue и React на одном экране и решение ADR-2026-08-11-spa-on-vue
- docs/conventions/web-ui.md переписан под Vue: компоненты, маршруты, состояние,
  обращение к API и показ ошибок
- закрыт вопрос «Приложение» в docs/architecture.md, уточнена задача spa-skeleton
2026-08-11 14:51:55 +03:00
av f1524fefd8 закрыта задача job-queue-choice 2026-08-11 14:13:41 +03:00
av df65eb5e32 docs: решено оставить очередь своей таблицей коллекцией PocketBase
- заведены записка разведки job-queue-choice и ADR: готовые библиотеки River и
  goqite отвергнуты, захват сворачивается в один запрос с RETURNING
- в architecture.md уточнён принцип «очередь таблицей» и закрыт открытый вопрос
  «Очередь», кроме холостого опроса
- задача pocketbase-storage забрала очередь себе: границы, счётчик попыток,
  состояние «мертва» и оракулы
2026-08-11 14:13:27 +03:00
av 6c996209b7 docs: применены находки вычитки по разведке PocketBase
- сняты повтор слова в записке разведки и та же строка в цитате ADR,
  убран термин «чекпоинт», разведена цепочка местоимений в паспорте
- oidc-login: «зачем» перестало повторять тело, задача заявила пункт 3
  «Завершения» цели multi-user — он не был закрыт ни одной её задачей
2026-08-11 13:17:28 +03:00
av 9b26c6aab1 закрыта задача pocketbase-admin-fit 2026-08-11 13:03:57 +03:00
av 10ffe8bec3 docs: решено перевести хранилище и файлы записей на PocketBase
- разведка pocketbase-admin-fit ответила замером панели версии 0.39.10:
  записка в docs/research/pocketbase.md, решение — в ADR
- панель показывает файлы и пользователей только своих, поэтому файлы
  переезжают в её раскладку, а вход идёт через её провайдера OIDC
- периметр расширился панелью на /_/ и паролем суперпользователя;
  закрывает её Authelia на прокси, задачи в беклоге у этого нет
2026-08-11 13:03:38 +03:00
av ba7b4f37a6 docs: устранены расхождения документов между собой и с кодом
- README разгружен: контракт HTTP API, таблицы БД, состояния задач и белый
  список отданы нормативным источникам ссылками
- в `architecture.md` и `review.md` числа и механика захвата задачи заменены
  ссылками на `database.md`, а описание конвейера ревью — на прогон от 2026-08-11
- в `CLAUDE.md` и `security.md` поправлены границы домена, оценка объёма записи
  и адрес очереди задач
2026-08-11 12:24:41 +03:00
av 2161d7f38e docs: канон документов поднят с версии 12 на 14
- `docs/.pm.json` переименован в `docs/.docs.json`, версия канона — 14
- ADR принимает записку разведки как источник решения наравне с архивным
  `design.md`
- заведён `tasks/.tasks.json`, а в описании гейта — шаги `tasks.py check` и
  `openspec.py check`
2026-08-11 12:24:27 +03:00
av faa1d7c699 tasks: заведена цель про изъятие записи из архива и задача удаления
- data-ownership — обратное право к бессрочному хранению: человек убирает свою
  запись вместе с файлом, объектом в Object Storage и всеми уровнями текста;
- delete-record закрывает все пять пунктов цели: подтверждение, необратимость,
  чужую запись не тронуть, повторная загрузка того же файла заводит новую
  задачу, а строки потребления остаются — деньги потрачены;
- docs/security.md: пункт «Удаление данных по требованию» перестал говорить,
  что задачи под это нет.
2026-08-11 10:54:43 +03:00
av a5884f1fcd security: модель угроз обновлена под расширившийся периметр
- заведён раздел «Куда уходит содержимое записи»: к Object Storage, SpeechKit и
  Telegram добавляются языковая модель за bifrost, канал уведомлений и почта;
- целевое разграничение доступа описано четырьмя механизмами вместо белого
  списка: сессия OIDC, владелец записи, личный токен, признак владельца
  сервиса; токен — первый секрет, который живёт в базе, а не в конфиге;
- исправлено неверное утверждение про логи: имя файла отправителя пишется
  строкой transcribe.go:107, чинит это задача no-user-filename-in-log;
- бессрочное хранение и отсутствие квот записаны в «Что вне модели» как
  следствие решения паспорта, а не как недосмотр.
2026-08-11 10:39:08 +03:00
av 9a54afba60 tasks: заведены три задачи под незакрытые пункты цели о долгих записях
- intake-limits-measure меряет четыре звена, которых не берёт speechkit-limits:
  приём из Telegram и по HTTP, конвертацию и заливку в Object Storage;
- reject-oversized-recording отклоняет запись сверх потолка на приёме,
  long-text-delivery отдаёт текст в сотни килобайт файлом вместо сотни
  сообщений Telegram;
- все шесть пунктов «Завершения» цели теперь закрыты задачами.
2026-08-11 10:28:56 +03:00
av d2c85af80a tasks: целевая картина пересобрана — три цели, тринадцать задач, сдвинутые границы
- паспорт: сервис объявлен архивом с бессрочным хранением записей и текстов,
  машинная вычитка расшифровки внутри границ, приложение — основной вход;
  добавлены две границы: не файловое хранилище общего назначения и не биллинг;
- заведены цели upload-reliability, user-settings, usage-stats и тринадцать
  задач; очередь пересобрана — сперва починки, затем разведки о хранилище,
  затем доступ и владелец, и только потом экраны;
- архитектура: четыре новых открытых вопроса — приём большого файла, учёт
  расхода, срок хранения, потолок шести часов.
2026-08-11 10:05:22 +03:00
av 2333e80633 tasks: заведены шесть задач из урожая ревью и переименования образца конфига
- пять из урожая change 2026-08-11-fix-http-handler-tests, тег review-2026-08-11;
  утечка имени файла в журнал поставлена первой строкой очереди
- опечатка transcibe дописана в json-api-for-spa: текст ошибки — часть
  необратимого контракта, и в одиночку он не правится
2026-08-11 08:59:45 +03:00
av 1bd6d03188 закрыта задача http-handler-tests-never-green 2026-08-11 08:35:37 +03:00
av 6c04c801c9 http: тесты приёма переписаны на подставные адаптеры
- проверки больше не зовут ffprobe и не меняют рабочий каталог процесса;
  добавлены случаи на отказ чтения метаданных и на отсутствие поля audio
- заведена спека intake на приём по HTTP, ADR о подставных адаптерах,
  запись в журнал ревью о проверке, которая не могла упасть
- go test снят из объявленных долгов CLAUDE.md, послабление errcheck
  для _test.go в .golangci.yml убрано
2026-08-11 08:35:11 +03:00
av bb973b0a68 Пересмотр очереди, выводы из текста, наблюдаемость
Разведка job-queue-choice — очередь написана вручную: захват не
транзакционен, повторов и счётчика попыток нет, очереди мёртвых задач
нет. Стоит перед сменой хранилища, чтобы не переписывать захват дважды.

Цель text-insights: заголовок, пересказ и темы внешним сервисом с
OpenAI-совместимым интерфейсом. Граница паспорта сдвинута — «понимание
сказанного» было записано как то, чем проект не является; за границей
остались ответы на вопросы по записи и поиск по смыслу.

Цель service-observability в сопровождении и разведка opentelemetry-fit:
/metrics остаётся и развивается, способ решает замер.
2026-08-10 21:46:08 +03:00
av d21da8575c Единый список задач вместо двух секций, порядок по выполнению
Секции «Ядро» и «Инфра» слиты в одну «Очередь»: полок домена у проекта
нет, а две секции держали два независимых порядка вместо одного.

Порядок: красный гейт, потом долги, задевающие интерфейсы, потом
хранилище, вход, разграничение, приложение. Разведка потолков SpeechKit
последней — она обслуживает направление, а не очередь.
2026-08-10 21:39:55 +03:00
av 115b3796e8 Нарезка задач под целевое состояние: PWA, вход, хранилище
Роадмап: web-access переименована под приложение, которое ставится на
телефон; заведена цель ready-notification — уведомление о готовности
без открытого приложения.

Беклог: десять задач. Многопользовательская цепочка (oidc-login,
record-ownership, telegram-account-link), веб (json-api-for-spa,
spa-skeleton, upload-and-status-screen, records-list-screen,
installable-pwa), уведомления через apprise и ntfy, разведка выбора
фреймворка. Очередь: долги, хранилище, вход, приложение.

Решение сменилось с htmx на SPA, поэтому conventions/web-ui.md снята
целиком и оставлена честной строкой до итога разведки. Открытые вопросы
архитектуры, границы паспорта и триггеры метки ревью приведены в
соответствие.
2026-08-10 21:36:54 +03:00
av 4d1c2bf44c Канон документов, каталог задач и OpenSpec
docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего
устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью.
Конвенции перенесены из jellybit; места, где код им не следует, помечены
строкой «Расхождение» как объявленный долг.

tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и
многопользовательский режим), два направления (все форматы, долгие
записи) и пять задач в беклоге.

openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет.

CLAUDE.md переписан по форме канона: инварианты с severity, семантика
гейта, запреты с путями. Taskfile получил task gate.
2026-08-10 21:19:07 +03:00
av a4646c0930 Инструкции для Claude, конфиг линтера, актуализация README
CLAUDE.md описывает конвейер задач, подвохи конфига и правило про четыре
списка колонок в репозитории sqlite.

.golangci.yml включает стандартный набор плюс errorlint; осознанные
непроверенные вызовы вынесены в исключения.

README приведён к коду: эндпоинты /api/audio и /api/status/:id, состояния
задач, структура internal/, фактические колонки таблиц.
2026-08-10 20:24:30 +03:00
398 changed files with 60292 additions and 2458 deletions
+13
View File
@@ -0,0 +1,13 @@
# Раскладка av-dev в этом проекте: версия и настройки проверок.
# Файл ведут скиллы плагина, править руками можно — комментарии свои.
version = 5 # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»
[docs]
# каталог миграций: по нему docs.py сверяет схему с database.md
migrations = "internal/adapter/repo/sqlite/migrations"
[tasks]
# каталог задач от корня репозитория; имена частей — умолчания скрипта
dir = "tasks"
stage = "build"
+155
View File
@@ -0,0 +1,155 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
+160
View File
@@ -0,0 +1,160 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+174
View File
@@ -0,0 +1,174 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+109
View File
@@ -0,0 +1,109 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
category: Workflow
tags: [workflow, artifacts, experimental]
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+143
View File
@@ -0,0 +1,143 @@
---
name: "OPSX: Sync"
description: Sync delta specs from a change to main specs
category: Workflow
tags: [workflow, specs, experimental]
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
+6
View File
@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"av-dev@av-dev-skills": true,
"av-dev-git@av-dev-skills": true
}
}
@@ -0,0 +1,159 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,117 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+289
View File
@@ -0,0 +1,289 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+113
View File
@@ -0,0 +1,113 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+147
View File
@@ -0,0 +1,147 @@
---
name: openspec-sync-specs
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
+30
View File
@@ -0,0 +1,30 @@
# Что не уезжает в контекст сборки образа.
#
# Файл заведён не ради веса: без него `COPY web/ ./` кладёт каталог зависимостей
# с машины собирающего **поверх** дерева, поставленного `npm ci` в контейнере, и
# ступень собирает приложение из того, что лежит у него, а не из файла замка.
# Сборка при этом зелёная — расхождение молчаливое.
#
# `.gitignore` этого не закрывает: docker его не читает.
# Зависимости и собранное приложение — ставит и собирает сама ступень образа.
web/node_modules/
web/embed/dist/
web/*.tsbuildinfo
# Боевые и локальные данные: каталог записей и база того, кто запускал сервис
# у себя. В образе им делать нечего.
data/
# Настройки с секретами. Образ берёт конфиг на сервере, а не из дерева.
config.toml
.env
# История репозитория: в слой сборки не нужна.
.git/
.gitignore
# Каталоги процесса, а не сборки.
openspec/
tasks/
docs/
+16 -9
View File
@@ -20,14 +20,9 @@ transcriber
# Go workspace file
go.work
# Database files
data/transcriber.db
data/transcriber.db-shm
data/transcriber.db-wal
# Uploaded files
data/files/*
!data/files/.gitkeep
# Каталог данных: файл базы, её журнал упреждающей записи, замок наката схемы и
# подкаталоги с файлами записей. Раскладку задаёт сервис.
data/
# IDE files
.vscode/
@@ -51,7 +46,19 @@ Thumbs.db
# Config files
config.toml
# Переменные окружения: сервис их не читает, настройки приезжают из TOML.
# Строка стоит против того, чтобы секрет завёлся здесь руками: из этого файла
# он попадает в git тем же способом, каким попал бы из конфига.
.env
# Sample and test audio files
*.m4a
*.mp3
*.ogg
*.ogg
# Приложение: зависимости и собранное. Метка `web/embed/.gitkeep` остаётся в
# git — без неё `go build ./...` отказывает у того, кто приложение не собирал.
web/node_modules/
web/embed/dist/
# Слепок проверки типов: его пишет сборка, и в git он значил бы «собрано у меня».
web/*.tsbuildinfo
+213
View File
@@ -0,0 +1,213 @@
# Линтеры проекта. Перечень правил и их дома — docs/conventions/go-linters.md,
# «Механизировано»; здесь только настройка и «почему именно так».
#
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
# staticcheck, unused. Сверх него включено то, что механизирует конвенции: то,
# что проверяет правило, прозой в конвенциях не остаётся.
version: "2"
linters:
default: standard
enable:
# docs/conventions/errors.md: сравнение ошибок через errors.Is и errors.As.
- errorlint
# docs/conventions/errors.md: ошибки — только stdlib.
- depguard
# docs/conventions/logging.md: форма вызова slog.
- sloglint
# Опечатка в комментарии и в тексте ошибки читается как термин проекта.
- misspell
# Запреты по месту: чем судят ответ в проверках, чем читают время, откуда
# берут конфигурацию, куда пишут вывод. Подробности у каждого правила ниже.
- forbidigo
# Отмена доходит до внешнего вызова: запрос и внешний процесс заводятся с
# контекстом. Инвариант «принятая запись не теряется молча» держится
# остановкой на середине, а не только записью в лог: `ffmpeg`, заведённый
# без контекста, переживает остановку воркера и дожёвывает чужую запись.
- noctx
# Контекст приезжает сверху, а не заводится по месту. `context.Background()`
# внутри адаптера обрывает цепочку отмены ровно на границе с платным
# внешним сервисом — там, где отмена и нужна.
- contextcheck
# Тело ответа закрывается. `errcheck` его не видит: `(io.ReadCloser).Close`
# объявлен в `exclude-functions` ниже, и незакрытое тело от невыясненного
# `Close` этим списком не отличается.
- bodyclose
# `return nil` после проверенной ошибки — это молчаливая потеря отказа,
# прямо запрещённая инвариантом об очереди (CLAUDE.md, major).
- nilerr
# Отказ выборки не теряется: неспрошенный `rows.Err()` превращает оборванное
# чтение в пустой результат.
- rowserrcheck
# `Rows` и `Stmt` закрываются: незакрытая выборка держит соединение.
- sqlclosecheck
# Форма утверждений в проверках: перепутанные местами «ожидалось/получено»,
# `assert` там, где после провала продолжать нельзя, `require` из горутины.
- testifylint
# Подавление — это решение: строчное `//nolint` обязано называть линтер и
# причину, а протухшее подавление обязано краснеть. Тот же порядок, что у
# подавлений в этом файле, но применённый к комментариям в коде.
- nolintlint
settings:
forbidigo:
# `analyze-types` включает суждение по типу приёмника, а не по печатному
# тексту вызова. Правилу о заголовках это необходимо (см. ниже), прочим
# правилам не мешает: имена пакетов в шаблонах те же.
analyze-types: true
forbid:
# Вывод идёт в журнал: строка в stdout мимо slog не имеет ни уровня, ни
# полей, и в разборе постфактум её не найти. Встроенные `print`/`println`
# названы тем же правилом: запрет на одно имя обходится соседним.
- pattern: '^fmt\.Print.*$'
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
- pattern: '^print(ln)?$'
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
# Конфигурация приезжает из TOML. Перечислены все способы прочитать
# окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv`
# одной правкой. Наш рабочий код окружение не читает вовсе — это
# правило и держит.
#
# Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и
# `os.Stdout.WriteString` — первый аргумент по имени функции не судится.
# Этот остаток назван прозой в docs/conventions/logging.md.
- pattern: '^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$'
msg: 'конфигурация только из TOML (docs/conventions/config.md)'
# Единая точка чтения времени — internal/clock: метка времени в UTC
# (`clock.Now`), измерение длительности с монотонными часами
# (`clock.Start`). Прежде время брали по месту, и хранилище сравнивало
# строками времена из разных зон.
- pattern: '^time\.Now$'
msg: 'время читают clock.Now (метка) и clock.Start (длительность) — docs/conventions/database.md'
# Проверка ответа судит по **готовому ответу**, а не по изменяемому
# состоянию обработчика. `httptest` устроен зеркально настоящему серверу:
# `Header()` отдаёт живую карту, доступную и после записи ответа, а
# снимок, который получит клиент, лежит отдельно и читается через
# `Result()`. Проверка, читающая живую карту, зелена при неработающем
# коде — класс всплывал трижды (docs/review.md, записи 2026-08-10,
# 2026-08-11 и 2026-08-12) и трижды стоил зелёного гейта.
#
# Правило судит по типу приёмника, и в этом весь смысл: запрет на
# цепочку `w.Header().Get` обходится одной лишней строкой —
# `h := w.Header()`, — а также чтением по индексу карты и обходом
# `range`. По типу под правило попадают все эти формы разом. Текстом его
# записать нельзя ещё и потому, что `.Header` носят и запрос
# (`req.Header.Set` в проверках законен), и снимок ответа
# (`w.Result().Header` — как раз то, к чему правило ведёт).
#
# Приёмник назван поимённо: подставной сервер в проверках отдаёт
# заголовок через `w.Header().Set`, но у него приёмник —
# `http.ResponseWriter`, и под правило он не попадает.
- pattern: '^httptest\.ResponseRecorder\.Header$'
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
# `HeaderMap` — тот же живой снимок прежним именем поля. Правило второе,
# потому что об устарелости поля говорит `staticcheck` (SA1019), а о том,
# почему по нему не судят ответ, — только это сообщение.
- pattern: '^httptest\.ResponseRecorder\.HeaderMap$'
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
sloglint:
# Стиль вызова один — пары «ключ-значение». `kv-only` запрещает атрибуты
# (`slog.String` и прочие) **целиком**, а не только смешение с парами:
# смешение и так запрещено умолчанием `no-mixed-args`. Решение осознанное —
# один стиль на весь код, — и записано строкой в
# docs/conventions/logging.md, «Сообщение».
no-mixed-args: true
kv-only: true
# `msg` — константа: сообщение с подставленным значением не сгруппировать
# отбором, а данные для этого и кладут в поля.
static-msg: true
# `key-naming-case` не включаем: словарь полей намеренно смешанный —
# доменные поля `snake_case`, системные домены с точкой (`http.method`,
# `ext.service`). См. docs/conventions/logging.md, «Поля: словарь имён».
depguard:
rules:
main:
deny:
- pkg: github.com/pkg/errors
desc: 'ошибки — только stdlib errors и fmt.Errorf (docs/conventions/errors.md)'
- pkg: github.com/cockroachdb/errors
desc: 'стек-трейс избыточен, контекст несёт цепочка %w (docs/conventions/errors.md)'
nolintlint:
# Подавление без причины снимают при первом же неудобстве: снимающий не
# знает, что оно ловило. Те же два требования, что у подавлений в этом
# файле, — имя линтера и причина строкой.
require-explanation: true
require-specific: true
# Подавление, которому нечего подавлять, — след починенного места, и
# краснеть оно обязано: иначе перечень подавлений врёт.
allow-unused: false
errcheck:
# Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не
# теряется молча» принимается реализацией, которая его теряет. Отказ,
# который решено не проверять, теперь объявляют ниже поимённо — заметно.
check-blank: true
# Непроверенное приведение типа паникует, а не отдаёт ошибку, поэтому
# `check-blank` его не ловит: `v := x.(T)` вовсе не про присваивание в `_`.
check-type-assertions: true
exclude-functions:
# Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки
- (io.Closer).Close
- (*database/sql.DB).Close
- (*os.File).Close
- (io.ReadCloser).Close
- os.Remove
# Закрытие выборки отложенным вызовом: строки к этому моменту прочитаны,
# а их отказ уже спрошен у `rows.Err()` — отдельного смысла у отказа
# закрытия нет.
- (*database/sql.Rows).Close
# Откат транзакции отложенным вызовом. Успешно завершённая транзакция
# отвечает на него «уже закончена», и проверка этого отказа означала бы
# разбор штатного исхода.
- (*database/sql.Tx).Rollback
# Запись тела ответа. Отказ здесь значит оборванное соединение, и
# сказать о нём некому: код ответа уже ушёл, а строка о каждом закрытом
# браузере наполняла бы журнал ничем.
- (*encoding/json.Encoder).Encode
- (net/http.ResponseWriter).Write
exclusions:
rules:
# Правило о заголовках живёт только в файлах проверок: в рабочем коде
# `Header()` и есть способ отдать заголовок.
- linters:
- forbidigo
path-except: '_test\.go$'
text: 'живой карте заголовков'
# Единая точка чтения времени сама читает время — иначе ей нечем.
- linters:
- forbidigo
path: 'internal/clock/'
text: 'time.Now'
# Проверка читает окружение **своего прогона** — `PATH`, чтобы убрать из
# него каталог с `go`, и `os.Environ()`, чтобы передать окружение дочернему
# процессу. Настройками приложения это не является. Исключение объявлено по
# тексту сообщения, а не по имени функции: правило называет четыре имени, и
# исключение обязано покрывать те же четыре.
- linters:
- forbidigo
path: '_test\.go$'
text: 'конфигурация только из TOML'
# Проверки строят время фикстур, а не метку домена: `time.Now` в них не
# обходит единую точку, а задаёт вход. Запрет здесь стоил бы обязательного
# обряда на каждый срок захвата в фикстуре и не поймал бы ничего.
- linters:
- forbidigo
path: '_test\.go$'
text: 'time.Now'
# `httptest.NewRequest` строит фикстуру для обработчика в том же процессе:
# внешнего собеседника за ней нет, и отменять у неё нечего — правило здесь
# говорит не о том, что мы имели в виду. Изъятие названо по имени этой
# функции, а не выключением `noctx` на проверках целиком: настоящий внешний
# вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему
# подсуден.
- linters:
- noctx
path: '_test\.go$'
text: 'httptest\.NewRequest'
formatters:
enable:
- gofmt
+347
View File
@@ -0,0 +1,347 @@
# CLAUDE.md
Памятка для работы над transcriber. Перед задачей прочитай также
[docs/passport.md](docs/passport.md),
[docs/architecture.md](docs/architecture.md) и
[docs/conventions/](docs/conventions/README.md).
Проект ведём по-русски.
## Что это
Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
SpeechKit и отдаёт текст тому, кто запись загрузил, карточкой записи.
Состояние записей и метаданные лежат в SQLite, файлы записей — своим каталогом
рядом с базой. Панели администратора у сервиса нет: встроенное хранилище,
дававшее её, убрано 2026-08-22. Вход Telegram убран 2026-08-14 — временно, до
задачи, которая свяжет чат с учётной записью.
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
руками не правит и в форматы документов не экспортирует, учётных записей не
заводит, с живым потоком не работает и складом произвольных файлов не служит.
Записи и расшифровки хранит бессрочно: решением от 2026-08-11 сервис — архив.
Перечень выше — выжимка, границу домена целиком держит
[docs/passport.md](docs/passport.md).
## Стек
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), SQLite через
`modernc.org/sqlite` — база, — шаги схемы библиотекой `pressly/goose/v3`,
маршруты и слои на `net/http`, файлы записей своим каталогом,
`aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3
с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют
его Biome и юнит-тесты Vue. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
## Проектирование
**Чистая архитектура.** Зависимости направлены внутрь, к домену: домен не знает
ни хранилища, ни транспорта, знание о внешнем мире приходит интерфейсом порта, а
реализацию подставляет точка входа. Направления держат тесты-сканеры
`internal/archrules`, а не договорённость.
**Модель домена ведётся тактическими шаблонами DDD** — сущность и корень
агрегата, объект-значение, доменное событие, репозиторий, служба домена,
фабрика. Анемичной модели не заводим: поведение записи живёт в домене, а
прикладной слой назначает порядок шагов, а не правила.
Слои, их дома, что каждому знать нельзя и чем шаблон занят сегодня —
[docs/architecture.md](docs/architecture.md), «Слои и модель домена». Здесь это
не повторяется: перечень растёт вместе с моделью, и вторая копия разошлась бы с
ним молча.
## Инварианты
Что нарушать нельзя.
- **Секрет не покидает конфиг.** Ключ SpeechKit и пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
вручную во всех местах выкладки. **critical**
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
на что. Чтение файла базы больше не равносильно чтению секрета. Секретов в
базе не осталось вовсе: пароль владельца от панели ушёл вместе с панелью.
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
**critical**
*Изъятие:* расширение — хвост после последней точки — в журнал попадает
собственным полем: по нему прослеживается путь записи. Имени файла в журнале
нет вовсе (инвариант ниже). Изъятие узкое
и кончается журналом: наружу, меткой метрики, расширение выходит только
приведённым к перечню известных форматов. Границу держит спека `intake`,
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md),
остаток — [docs/security.md](docs/security.md).
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
запись пригодной к повтору, либо ставит на неё признак остановки с причиной —
и тогда причина видна её владельцу **карточкой записи**, а владельцу сервиса
журналом. Молчаливый выход из шага без записи в лог и без смены состояния
запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом
Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и
отправитель, который не спрашивает, о нём не узнаёт. Адрес, которым он
спрашивает, сменился 2026-08-15: опрос готовности убран, и обязанность целиком
переехала на карточку. **major**
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major**
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
файлом шага. Необратимо: учёт применённого ведёт сама база.
**critical**
*Снятие было, разовое:* 2026-08-22 решением владельца весь каталог шагов
встроенного хранилища удалён и заменён одним шагом начальной схемы. Причина —
стройка: на сервере данных нет, сервис остановлен, выкладка идёт с чистого
листа, а новая база ведёт учёт применённого своей таблицей, которой отметки
прежнего каталога не годятся вовсе. Граница названа: снятие кончилось этим
изменением, и шаг начальной схемы подпадает под инвариант как всякий прежний.
- **Имя файла на диске задаёт сервис, а в журнал не идёт.** Ни имя файла, ни имя
подкаталога записи не строятся из имени, данного отправителем: подкаталог зовётся
идентификатором записи, файл — идентификатором с расширением. В журнал пишется
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
записи. **critical**
- **Колонки записи правятся в трёх местах** пакета хранилища —
`writeOwnedByPipeline` вместе с `writeRecord`, `readRecordColumns` и
`rowToAudioRecord`, — плюс шаг схемы. Компилятор не видит ни одного: колонка,
забытая в одном из них, теряется молча — запись сохранится без поля, приедет с
нулевым либо доедет до сущности пустой, и ближайшее сохранение запишет этот
ноль поверх сохранённого.
Мест было четыре, пока захват перечислял колонки поимённо; теперь он
возвращает идентификатор и признак своего захвата, и перечень перестал расти
с моделью. Отображение при этом идёт **по имени колонки**: именованные
параметры запроса и место назначения, найденное по имени, — позиционный список
дал бы сдвиг на одно поле, который компилируется молча. Сверку держат правила
`internal/archrules`. **major**
- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
ниже не пишется в журнал и не считается в метрику: запись встанет без единого
следа. Сверку держат правила `internal/archrules`. **major**
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
Признак захвата уникален для каждого захвата, и запись результата условна по
нему, а не по занятости записи. Шаг, чей захват за время работы достался
другому — по протуханию срока или после того, как человек вернул запись в
работу подкомандой оснастки, — завершается без записи результата. Условие
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
по очереди, портя её результат. **major**
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
запись не заводится ничем — ни приёмом, ни конвейером, ни запросом к базе, — и
держит это схема, а не договорённость: колонка объявлена внешним ключом на
учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью
запись заводили руками мимо него, она уходила в конвейер, стоила денег на
распознавание и не доставалась потом никому. Правило со стороны
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
записью, потому что схема запрещает **заводить** ничью, а это правило —
**спрашивать** ничьим именем. **major**
- **Остановленная запись несёт причину, какой бы та ни была.** Причин три —
приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму
запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит
исход «пригодна к повтору» исключён, и другого следа у неё не будет.
Обязанность, записанная у одной причины, у остальных читалась бы как снятая.
**major**
## Команды
```bash
go build ./... # CGO не нужен
go test ./... # в гейте идёт с -race, и там нужен компилятор C
go vet ./...
gofmt -l .
golangci-lint run
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
go run ./cmd/devtools resume -c config.toml <id> # вернуть остановленную запись в работу
task front # приложение: зависимости, Biome, юнит-тесты, сборка
task image # docker-образ; тег и раскладка — docs/architecture.md
task gate # весь набор проверок разом
```
Node на машину **не ставится**: шаг сборки приложения зовёт его контейнером, а
образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому
становится docker — тот же, которым собирается образ.
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml`
скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
в [docs/conventions/config.md](docs/conventions/config.md) строками
«*Расхождение:*».
## Гейт
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
`origin/master`; переопределяется `task gate BASE=<rev>`.
- **Какое правило чем проверяется** — конвенция
[docs/conventions/go-linters.md](docs/conventions/go-linters.md). Здесь
семантика гейта, там перечень правил, подавлений и место настройки каждого;
перечень здесь не повторяется.
- **Где логи шагов:** вывод команды, отдельного файла нет.
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
`docs.py check`, `tasks.py check`, `openspec.py check` и
`scripts/check-go-version.sh` словарь кодов общий: 0 сошлось, 1 дрейф,
2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не
найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно:
четвёртый шаг с собственной семантикой сделал бы это утверждение неверным.
Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая
`tests`, `migrations`, `shell`, `dockerfile` и `vulns`: недостающий инструмент
— отказ окружения, код 3. У `tests` это отсутствие CGO или компилятора C, без
которых не работает детектор гонок — тесты он в этом случае всё равно гоняет,
без `-race`, и краснеет уже после них. У `migrations` код 3 — неразрешимая
база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 —
переписанный шаг схемы. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`,
`golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой».
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
любой отказ шага выходит с 201, а код шага печатает строкой
(«exit status 3»), поэтому словарь читается по коду скрипта.
- **Что красит безусловно и почему:** красная сборка приложения, находка Biome,
красный юнит-тест приложения, отказ сборки, тестов, `go vet`,
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
схемы,
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
версий Go, дрейф раскладки документов,
дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода
уязвимость в зависимостях (`govulncheck`), находка `shellcheck` в скриптах
оболочки и `hadolint` в `Dockerfile`. Машина проверяет всё
перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с
именем недостающего плагина, а не пропускается молча.
- **Что ловит pre-commit, а что только гейт.** `lefthook.yml` гоняет на
**затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в
коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`,
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
сверка версий Go, сверки документов и `govulncheck`: они смотрят всё
дерево либо требуют сети, а pre-commit обязан быть быстрым.
- **Сетезависимые шаги названы здесь поимённо, и перечень этот закрыт.** Один из
них — `front`: он ставит зависимости приложения из реестра пакетов, а docker до
того тянет образ сборочного окружения. Отказ сети и реестра там — отказ окружения, код 3,
отдельно от красной сборки, у которой код 1; полный кэш установщика снимает
поход в сеть вовсе. Без короткого обращения-пробы шаг **висел** бы вместо
отказа: установщик уходит в повторы с нарастающей паузой на каждом пакете, а
гейт, который висит, хуже красного.
- **Шагу `vulns` нужна сеть** тоже: база уязвимостей живёт на
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он
достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем,
шаг не роняет. Такая сегодня одна — `GO-2026-5932` в
`golang.org/x/crypto/openpgp`, исправления у неё нет вовсе.
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
- **сборка образа** — дорога, и отказ от неё сознательный. Дешёвая замена
стоит шагом сверки версий: он сравнивает строки и ловит расхождение, из-за
которого образ перестаёт собираться, но собираемости не проверяет. Собрать
образ по-прежнему может только человек — `task image`, и на подъёме версии
это обязательно;
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и часть его
правил подавлена поимённо — `DL3007` до задачи `pin-runtime-image-base` и
`DL3018` по существу (alpine не держит старые версии пакетов, закрепление
ломает сборку через недели). Причины стоят строками в `Taskfile.yml`;
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
коммита. Полную историю никто не проверяет;
- согласованность документов между собой и с кодом — её судят агенты, зовёт
их скилл `av-dev:doc-healthcheck`, и звать его надо руками;
- покрытие изменённых строк не считается ничем.
**Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.**
Красный шаг означает поломку — свою или чужую, но поломку, а не наследство.
Списывать отказ на долг больше нельзя: списывать не на что.
Три прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
новый:
- `hadolint` давал `DL3066` на строке `USER transcriber` — «Non-numeric user-id
may not be resolvable by host system». Отказ пришёл с обновлением `hadolint`,
а не с правкой репозитория, и жил на `master` незамеченным. Закрыто решением
владельца 2026-08-22: пользователь называется числом — `USER 1000:1000`.
Владельца файлов в смонтированном каталоге это не двигает, потому что те же
числа уже стояли при заведении пользователя (`-u 1000`, `-g 1000`);
- `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два
сравнения ошибок приведением типа. Закрыто задачей
`errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена
настройка `check-blank`, поэтому `_ = x.Close()` больше не снимает замечание:
отказ, который решено не проверять, объявляют в `exclude-functions` поимённо;
- `go test ./...` чинила задача `http-handler-tests-never-green`.
## Запреты
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
база (`data/transcriber.db`), и записи живых людей
(`data/records/<запись>/`). На стройке под ним пусто и сервис
остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
его ронять и пересоздавать можно свободно.
- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
проверяются на старте, но наружу при этом не обращаются. У `[auth]` остался
один ключ — перечень доверенных адресов, — и он проверяется на читаемость, а не
на достижимость. Расшифровка при выдуманных ключах не работает: её подменяют
`internal/adapter/recognizer/memory.go`. Подробности строками в
`config.example.toml`.
**На машине без прокси представиться нечем**: сервис узнаёт
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
не ставит. Заголовок подставляет сам сервис — настройками, а не вторым
процессом: рецепт из трёх правок записан связным блоком в
`config.example.toml`, под перечнем доверенных адресов. Приложение при этом
открывают по адресу сервиса, второго порта нет. Заполненная имитация при
выключенном предохранителе роняет старт с именем ключа. Ключей боевого
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`.
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
запускает человек.
- **Проверок над проверками не заводить.** Уровень проверки один: линтеры и
тесты судят код сервиса, а судить их самих незачем. Под запрет попадают тесты
на шаги гейта и на свои скрипты проверок, стражи предмета у правил,
механизация покрытия изменённого кода, мутационная сверка оракулов и
требование мутировать тест, чтобы убедиться в его способности упасть. Решение
владельца 2026-08-13; им закрыты четыре задачи — причины и даты в
[tasks/REJECTED.md](tasks/REJECTED.md), — и тем же решением снесены двадцать
сценариев шага сверки версий Go, единственный такой файл в проекте.
Исключений у запрета нет.
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
временном каталоге и убирают за собой.
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
писать: этот каталог смонтирован на сервере.
## Работа
- **Стадия проекта — стройка** (`[tasks] stage = "build"`, объявлена в
[tasks/BACKLOG.md](tasks/BACKLOG.md)). Приложение строим заново: на сервере
данных нет, сервис остановлен, выкладка пойдёт с чистого листа. Совместимость
с тем, что уже лежит на сервере, поэтому не требуется — переносить нечего: ни
базы, ни файлов записей, ни истории. Что это **не** отменяет: гейт краснеет на
переписанном шаге схемы, как и краснел, и снятие этого запрета — отдельное
решение человека; боевой каталог данных остаётся под запретом; выкладку
по-прежнему запускает человек.
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
- **Сообщение коммита** без трейлера `Co-Authored-By`.
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
секрета.
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
твоей правки. Такое чинится прежде любой другой работы. Исключений из этого
правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать
красный шаг больше не на что.
- **Ориентир по размеру порции:** не замерялся.
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
поимённо.
## Язык
- Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский.
- Текст, который видит пользователь сервиса, — русский.
- **Точного числа накопленного в документах нет.** «Три capability», «пять
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
такое не считает, а читатель верит написанному. Ссылаться можно только на
**конкретную запись** (по имени, со ссылкой) либо на **весь корпус разом**
(«заведённые capability», «записи журнала ниже»). Само перечисление при этом
законно: перечень обновляют вместе с предметом, а число живёт отдельно от него
и потому протухает в одиночку.
*Изъятие:* число, которое не растёт с работой, остаётся числом — количество
уровней журнала в библиотеке, ступеней сборки образа, состояний списка на
экране. Так же законно **историческое** число в записи о прошлом: «решением от
2026-08-13 закрыты четыре задачи» описывает событие, а не сегодняшний счёт.
Настройки с числовым значением — свой случай, их дом
[docs/database.md](docs/database.md).
+42 -9
View File
@@ -1,11 +1,33 @@
# Build stage
FROM docker.io/library/golang:1.24-alpine AS build-env
# Front build stage
#
# Приложение собирается до бинарника: вшивание требует готового каталога.
# Имя этого образа — единственное; шаг набора проверок берёт его отсюда же,
# чтобы версия сборочного окружения не жила вторым числом в Taskfile.yml.
#
# Образ на glibc, а не на alpine, и разница здесь не в весе: musl шлёт запросы
# `A` и `AAAA` разом и ждёт **оба** ответа, а DNS-сервер, который на `AAAA`
# молчит, оставляет его без адреса вовсе — при живом `A`. `npm` на такой отказ
# уходит в повторы с нарастающей паузой на каждом пакете, и сборка не краснеет,
# а **висит**. Библиотека glibc довольствуется полученным `A` и собирает.
# Ступень сборочная: в готовый образ её слои не едут, и лишний вес остаётся
# ценой одной сборки, а не размером выкладки.
FROM docker.io/library/node:24 AS front-build
# Install build dependencies
RUN apk --no-cache add \
build-base \
sqlite-dev \
&& rm -rf /var/cache/apk/*
WORKDIR /web
# Зависимости ставятся из файла замка командой, которая его не правит:
# иначе собранное в образе перестаёт совпадать с собранным в наборе проверок.
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Build stage
FROM docker.io/library/golang:1.26-alpine AS build-env
# Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
# и CGO больше не требуется.
# Set up the working directory
WORKDIR /app
@@ -19,8 +41,14 @@ RUN go mod download
# Copy source code
COPY . .
# Собранное приложение приезжает ступенью выше: в дереве сборки его нет,
# а вшивание без него отдаёт бинарник, который отвечает «приложение не собрано».
COPY --from=front-build /web/embed/dist ./web/embed/dist
# Build the application
RUN go build -o transcriber .
# Собирается одна точка входа из cmd/, а не весь пакет: соседний cmd/devtools —
# оснастка разработчика (подставной прокси), и в образе ей делать нечего.
RUN CGO_ENABLED=0 go build -o transcriber ./cmd/transcriber
# ----------------
# Production stage
@@ -63,7 +91,12 @@ COPY docker/entrypoint.sh /usr/bin/entrypoint
RUN chmod 755 /usr/bin/entrypoint
# Set user
USER transcriber
#
# Числом, а не именем: имя разрешает в идентификатор сам образ, и хост, которому
# нужно понять владельца файлов в смонтированном каталоге, разрешить его не
# может. Числа те же, что заданы выше при заведении пользователя (`-u 1000`,
# `-g 1000`), поэтому владелец файлов не меняется — меняется только запись.
USER 1000:1000
EXPOSE 8080
+101 -106
View File
@@ -1,22 +1,25 @@
# Transcriber Service
Сервис для расшифровки аудиозаписей с REST API.
Сервис расшифровки аудиозаписей. Вход один — HTTP API.
## Возможности
- Загрузка аудиофайлов любого формата
- Автоматическая генерация UUID для файлов
- Сохранение файлов на диск
- Приём аудиофайлов через HTTP API
- Конвертация в ogg через ffmpeg
- Распознавание речи через Yandex SpeechKit
- Отслеживание статуса задач расшифровки
- SQLite база данных для хранения метаданных
- Своё хранилище: SQLite для метаданных и каталог файлов записей рядом с ним; метрики Prometheus
## Технологии
- **Веб-фреймворк**: gin-gonic/gin
- **SQL Builder**: doug-martin/goqu
- **Миграции БД**: pressly/goose
- **База данных**: SQLite
- **UUID**: google/uuid
- **Язык**: Go 1.26, CGO не нужен
- **HTTP**: стандартная библиотека, `net/http`
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
- **Конвертация**: ffmpeg
- **База данных**: SQLite через modernc.org/sqlite, CGO не нужен
- **Шаги схемы**: pressly/goose/v3, библиотекой — накат при старте
- **Файлы записей**: свой каталог, подкаталог на запись
- **Метрики**: prometheus/client_golang
## Установка и запуск
@@ -25,12 +28,36 @@
```bash
go mod tidy
```
3. Запустите приложение:
3. Скопируйте образец конфига и заполните его:
```bash
go run main.go
cp config.example.toml config.toml
```
4. Запустите приложение:
```bash
go run ./cmd/transcriber -c config.toml
```
Сервер запустится на порту 8080.
Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен
установленный `ffmpeg`.
### Кого пускают
Кто пришёл, сервис узнаёт из заголовка `Remote-User`, который ставит обратный
прокси, сходив к Authelia; своего входа у сервиса нет. Кому верить, задаёт
перечень доверенных адресов в секции `[auth]`. Подробности —
[docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
Локально прокси нет, а браузер заголовков не ставит — заголовок подставляет сам
сервис по своим настройкам. Второго процесса для этого не нужно: приложение
открывают по адресу сервиса.
Рецепт целиком — связным блоком в `config.example.toml`, под перечнем доверенных
адресов: там названы три правки, принимаемые имена заголовков и цена включения.
Заполненная секция имитации при выключенном предохранителе роняет
старт с именем ключа: сервис с включённым предохранителем называет пришедшего
сам, никого не спросив, и в бою этот ключ стоит `false`.
## Деплой
@@ -44,115 +71,83 @@ inv pl -- transcriber
локально и едет на сервер через `docker save`/`load`, реестр не участвует.
Локально образ можно собрать и руками — `task image` даст `transcriber:dev`.
## API Endpoints
## HTTP API
### POST /api/transcribe
Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём
записи, `GET /app/audiorecords` — страница своих записей,
`GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` —
текст названного вида, `GET /app/audiorecords/{id}/file` — файл записи названной
копии, `GET /app/me` — кто пришёл, `GET /app/config` — пределы, которые сервис
объявляет приложению. Отдельными адресами стоят `GET /metrics` — метрики
Prometheus с префиксом `transcriber_` — и `GET /health` — проверка живости.
Своего входа у сервиса нет: кто пришёл, называет заголовок обратного прокси
([access](openspec/specs/access/spec.md)). Больше на этом порту не отвечает
ничего: всякий прочий путь получает разметку приложения.
Загружает аудиофайл и создает задачу на расшифровку.
Контракт приёма и опроса нормативен и живёт в
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
ответа, коды и условия. Менять его — необратимое действие
([CLAUDE.md](CLAUDE.md), «Работа»), и второго описания у него быть не должно.
**Параметры:**
- `audio` (form-data) - аудиофайл для расшифровки
## Состояния задач
**Пример запроса:**
```bash
curl -X POST \
http://localhost:8080/api/transcribe \
-F "audio=@/path/to/your/audio.mp3"
```
**Ответ:**
```json
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"file_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"status": "pending"
}
```
### GET /api/transcribe/:id
Получает статус задачи расшифровки по ID.
**Пример запроса:**
```bash
curl http://localhost:8080/api/transcribe/550e8400-e29b-41d4-a716-446655440000
```
**Ответ:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"file_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"created_at": "2024-01-01T12:00:00Z",
"updated_at": "2024-01-01T12:00:00Z"
}
```
### GET /health
Проверка работоспособности сервиса.
**Ответ:**
```json
{
"status": "ok",
"message": "Transcriber service is running"
}
```
## Статусы задач
- `pending` - задача создана, ожидает обработки
- `processing` - задача выполняется
- `completed` - задача завершена успешно
- `failed` - задача завершена с ошибкой
Перечень состояний, переходы между ними и число воркеров —
[docs/database.md](docs/database.md), разделы «Таблицы» и «Представление
данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md).
## Структура проекта
```
transcriber/
├── main.go # Точка входа приложения
├── go.mod # Зависимости Go
├── models/
│ └── models.go # Модели данных
├── database/
── database.go # Слой работы с БД
├── handlers/
── transcribe.go # HTTP обработчики
├── migrations/
│ ├── 001_create_files_table.sql
── 002_create_transcribe_jobs_table.sql
└── data/
── files/ # Директория для сохранения файлов
└── transcriber.db # SQLite база данных (создается автоматически)
├── cmd/
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
│ └── devtools/ # Оснастка разработчика: возврат остановленной записи в работу
├── internal/
│ ├── entity/ # Модели: запись, файл, результат распознавания
── ident/ # Выдача и разбор идентификаторов строк (ULID)
│ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
── config/ # Разбор config.toml
├── metrics/ # Метрики Prometheus
│ ├── service/ # Конвейер расшифровки
── controller/
│ │ ├── http/ # HTTP-обработчики
│ │ ── worker/ # Фоновые воркеры
└── adapter/
│ ├── converter/ffmpeg/ # Конвертация аудио
│ ├── metaviewer/ffmpeg/ # Длительность аудио
│ ├── recognizer/yandex/ # SpeechKit + Object Storage
│ └── repo/sqlite/ # Репозитории, подключение к базе, шаги схемы, каталог файлов
└── data/ # Каталог данных: база и файлы записей вместе
├── transcriber.db # База (создаётся автоматически)
├── migrate.lock # Замок наката схемы
└── records/ # Файлы записей: подкаталог на запись
```
## База данных
## Хранилище
### Таблица `files`
- `id` (TEXT) - UUID файла
- `type` (TEXT) - MIME-тип файла
- `size` (INTEGER) - размер файла в байтах
- `created_at` (DATETIME) - время создания
Таблицы базы — аудиозапись и её приложения. Поля, ключи, правило времени и
идентификаторов, раскладка файлов записи и механика захвата задачи воркером —
[docs/database.md](docs/database.md). Панели владельца у сервиса нет: единственное
его действие вне экранов — возврат остановленной записи в работу подкомандой
оснастки.
### Таблица `transcribe_jobs`
- `id` (TEXT) - UUID задачи
- `status` (TEXT) - статус задачи
- `file_id` (TEXT) - ссылка на файл
- `created_at` (DATETIME) - время создания
- `updated_at` (DATETIME) - время последнего обновления
```bash
go run ./cmd/devtools resume -c config.toml <идентификатор записи>
```
## Разработка
Для добавления новых миграций используйте goose:
Схему двигают шаги `pressly/goose/v3` —
`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия шага — число в
начале имени файла. Непринятые шаги накатываются при старте, прежде чем поднимутся
входы и стартуют воркеры; отказ шага роняет старт. Применённый шаг не
переписывается: изменение — только новым файлом шага.
Проверки перед коммитом — одной командой:
```bash
# Создание новой миграции
goose -dir migrations create migration_name sql
task gate
```
# Применение миграций
goose -dir migrations sqlite3 data/transcriber.db up
# Откат миграций
goose -dir migrations sqlite3 data/transcriber.db down
Что она гоняет, чем краснеет и какой отказ считается объявленным долгом —
[CLAUDE.md](CLAUDE.md), раздел «Гейт».
+293
View File
@@ -4,9 +4,302 @@ version: '3'
vars:
PROJECT: "transcriber"
# База диффа для шагов, которым нужна разница с основной веткой.
BASE: '{{.BASE | default "origin/master"}}'
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит
# дрейф своего каталога, и выпадение одного не подменяется другим.
#
# Недостающий скрипт — отказ окружения у всех обёрток ниже, и код у него 3 по
# общему словарю (CLAUDE.md, раздел «Гейт»). Прежний код 1 значил «дрейф» и
# отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам
# `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается
# по коду скрипта, а не по коду `task`.
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/canon/scripts/docs.py"}}'
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/task-track/scripts/tasks.py"}}'
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/code-openspec/scripts/openspec.py"}}'
tasks:
gate:
desc: 'Все проверки разом. База диффа: task gate BASE=<rev>'
cmds:
# Приложение собирается первым: вшивание требует готового каталога, и
# `go build` без него соберёт бинарник со вчерашней сборкой.
- task: front
- go build ./...
- go vet ./...
- |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "$unformatted"
echo "gofmt: файлы выше не отформатированы"
exit 1
fi
- task: tests
- golangci-lint run
- task: shell
- task: dockerfile
- task: go-version
- task: migrations
- task: docs
- task: tasks
- task: openspec
# Последним: единственный шаг, которому нужна сеть, и самый долгий.
- task: vulns
tests:
desc: 'Тесты с детектором гонок'
cmds:
# Гонки ищет детектор, а не чтение кода: у сервиса три воркера ходят в одну
# очередь, и «результат пишет только держатель захвата» — утверждение о
# одновременном доступе. Детектору нужен CGO и компилятор C; сборка
# приложения по-прежнему обходится без них (CLAUDE.md, «Стек»), поэтому их
# отсутствие — отказ окружения, код 3, а не отказ проверки.
# Окружение проверяется **после** обычного прогона, а не вместо него:
# отсутствие компилятора отнимает у гейта поиск гонок, но не должно
# отнимать сами тесты. Порядок проверок — сперва компилятор: без него
# совет «включи CGO_ENABLED=1» бесполезен.
- |
if ! command -v gcc >/dev/null 2>&1 && ! command -v clang >/dev/null 2>&1; then
go test ./... || exit 1
echo "тесты прошли, но гонки не искали: детектору нужен компилятор C"
echo "ни gcc, ни clang не найдены в PATH; поставь: apt install gcc"
exit 3
fi
if [ "$(go env CGO_ENABLED)" != "1" ]; then
go test ./... || exit 1
echo "тесты прошли, но гонки не искали: детектору нужен CGO"
echo "CGO_ENABLED=$(go env CGO_ENABLED); включи: CGO_ENABLED=1 task gate"
exit 3
fi
go test -race ./...
migrations:
desc: 'Применённый шаг схемы не переписывается'
cmds:
# Инвариант CLAUDE.md (critical): хранилище считает применённое по имени
# файла шага, поэтому изменить уехавший шаг нельзя — только добавить новый.
# Компилятор этого не держит, и до этого шага не держало ничто.
#
# Судится каталог шагов против базы диффа: у файла шага допустим один
# статус — `A`. Правка (`M`), удаление (`D`) и переименование (`R`) красят.
# `migrations.go` под правило не подпадает: строка `Register` у нового шага
# прибавляется именно там, и запрет на него запретил бы заведение шага.
- |
if ! git rev-parse --verify --quiet "{{.BASE}}" >/dev/null 2>&1; then
echo "база диффа не найдена: {{.BASE}}"
echo "задай свою: task migrations BASE=<rev>"
exit 3
fi
# Каталог шагов берётся из .av-dev.toml — там он уже записан ключом
# `migrations` секции `[docs]` для сверки документов. Свой литерал завёл
# бы факту второй дом: каталог переехал бы, а один из двух стражей молча
# позеленел. До слияния плагинов файл звался docs/.docs.json.
dir=$(python3 -c 'import tomllib; print(tomllib.load(open(".av-dev.toml","rb"))["docs"]["migrations"])' 2>/dev/null) || dir=""
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
echo "каталог шагов схемы не найден: ключ [docs] migrations в .av-dev.toml → '$dir'"
exit 3
fi
# Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от
# одного переименования — тот же приём, что у правил `internal/archrules`.
if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then
echo "в $dir нет ни одного файла шага: правило потеряло предмет"
echo "поправь шаблон имени в этом шаге либо ключ [docs] migrations в .av-dev.toml"
exit 3
fi
# Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал»
# ровно настолько, насколько свеж `origin/master`: отставшая ссылка
# читает весь каталог как добавленный, и правило молчит. `HEAD` ловит
# правку закоммиченного шага в рабочем дереве независимо от ссылки.
for base in {{.BASE}} HEAD; do
touched=$(git diff --name-status "$base" -- "$dir" \
| grep -E '[0-9]{12}_[^/]*\.go$' \
| grep -vE '^A[[:space:]]' || true)
if [ -n "$touched" ]; then
echo "база $base:"
echo "$touched"
echo "применённый шаг схемы переписан: изменение схемы — только новым файлом шага"
echo "(CLAUDE.md, «Инварианты», critical: хранилище считает применённое по имени файла)"
exit 1
fi
done
front:
desc: 'Приложение: зависимости, проверки, сборка'
vars:
# Образ берётся из Dockerfile: там он объявлен ступенью сборки. Второй дом
# версии сборочного окружения разошёлся бы с первым молча, а своего шага
# сверки у него, в отличие от версий Go, нет.
NODE_IMAGE:
sh: grep -oP '^FROM \K\S*node:\S*' Dockerfile | head -1
# Кэш установщика лежит вне дерева проекта: внутри контейнера он не пережил
# бы прогон, и каждый набор проверок тянул бы зависимости заново.
NPM_CACHE: '{{.NPM_CACHE | default "/tmp/transcriber-npm-cache"}}'
cmds:
# Node на машину не ставится — он зовётся контейнером, тем же образом,
# каким собирается ступень образа. Требованием к машине остаётся docker.
- |
set -eu
if ! command -v docker >/dev/null 2>&1; then
echo "docker не найден в PATH"
echo "приложение собирается контейнером: https://docs.docker.com/engine/install/"
exit 3
fi
if [ -z "{{.NODE_IMAGE}}" ]; then
echo "в Dockerfile не нашлось ступени с образом node"
exit 3
fi
mkdir -p "{{.NPM_CACHE}}"
run() {
docker run --rm \
-u "$(id -u):$(id -g)" \
-e npm_config_cache=/npmcache \
-v "{{.NPM_CACHE}}:/npmcache" \
-v "$PWD/web:/web" \
-w /web \
"{{.NODE_IMAGE}}" \
sh -c "$1"
}
# Зависимости ставятся из файла замка командой, которая его не правит:
# иначе набор проверок пачкал бы рабочее дерево, а собранное им
# расходилось бы с собранным в образе.
#
# Сперва — установка из кэша, без единого обращения наружу: кэш лежит
# вне дерева проекта и переживает прогоны, поэтому обычный случай сети
# не требует вовсе.
if ! run 'npm ci --offline' >/dev/null 2>&1; then
# Кэша не хватило — значит нужна сеть, и её наличие проверяется одним
# коротким обращением. Без этой проверки установщик уходит в повторы с
# нарастающей паузой и **висит на каждом пакете**: гейт, который висит,
# хуже красного — он не даёт ни исхода, ни причины.
if ! run 'npm ping --fetch-timeout=15000 --fetch-retries=0' >/dev/null 2>&1; then
echo "реестр пакетов недоступен"
echo "шагу нужна сеть: он ставит зависимости приложения и тянет образ"
exit 3
fi
# Сеть здесь уже заведомо есть — проба реестра прошла. Значит всякий
# отказ установки это отказ проекта: замок разошёлся с package.json,
# пакет снят из реестра, сломался его postinstall. По словарю кодов
# это дрейф, а не окружение: код 3 отправил бы человека чинить docker
# и сеть вместо `git diff web/package-lock.json`.
if ! run 'npm ci'; then
echo "зависимости приложения не установились, а реестр доступен"
echo "смотри расхождение web/package-lock.json с web/package.json"
exit 1
fi
fi
run 'npm run check && npm run test && npm run build'
shell:
desc: 'shellcheck на скрипты оболочки'
cmds:
# Скриптов два и оба свои: шаг сверки версий и `docker/entrypoint.sh`.
# Второй в образ копируется, но не исполняется — `ENTRYPOINT` в
# `Dockerfile` закомментирован, — и проверяется он именно поэтому: код,
# который никто не гоняет, портится незаметно. Ни один из двух не виден ни
# `go vet`, ни `golangci-lint`.
- |
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck не найден в PATH"
echo "поставь: apt install shellcheck (или https://github.com/koalaman/shellcheck)"
exit 3
fi
shellcheck scripts/check-go-version.sh docker/entrypoint.sh
dockerfile:
desc: 'hadolint на Dockerfile'
cmds:
# DL3007 (`alpine:latest` у рантайм-слоя) подавлен: это открытая задача
# `pin-runtime-image-base`, и до её решения шаг краснел бы на известном.
# DL3018 (закрепить версии пакетов `apk`) подавлен по существу: alpine не
# держит старые версии в репозитории, и закрепление ломает сборку через
# недели — то есть лечение хуже болезни.
- |
if ! command -v hadolint >/dev/null 2>&1; then
echo "hadolint не найден в PATH"
echo "поставь: https://github.com/hadolint/hadolint/releases"
exit 3
fi
hadolint --ignore DL3007 --ignore DL3018 Dockerfile
go-version:
desc: 'Одна версия Go в go.mod, Dockerfile, CLAUDE.md и README.md'
cmds:
# Скрипт лежит в самом репозитории, а не в плагине: его отсутствие значит
# сломанное дерево, а не непоставленный плагин, и переопределять путь
# нечем и незачем. Код отсутствия — 3, как у прочих обёрток.
- |
py=scripts/check-go-version.sh
if [ ! -f "$py" ]; then
echo "$py не найден: дерево репозитория неполно"
exit 3
fi
sh "$py"
docs:
desc: 'Раскладка docs/ против канона'
cmds:
# Шаг обязан краснеть внятно, если скрипта нет, а не пропускаться молча.
- |
py=$(eval echo {{.DOCS_PY}})
if [ ! -f "$py" ]; then
echo "docs.py не найден: $py"
echo "поставь плагин av-dev либо задай путь: task docs DOCS_PY=<путь>"
exit 3
fi
python3 "$py" check --base {{.BASE}}
tasks:
desc: 'Согласованность каталога задач'
cmds:
- |
py=$(eval echo {{.TASKS_PY}})
if [ ! -f "$py" ]; then
echo "tasks.py не найден: $py"
echo "поставь плагин av-dev либо задай путь: task tasks TASKS_PY=<путь>"
exit 3
fi
python3 "$py" check --dir tasks
openspec:
desc: 'Форма openspec/config.yaml'
cmds:
- |
py=$(eval echo {{.OPENSPEC_PY}})
if [ ! -f "$py" ]; then
echo "openspec.py не найден: $py"
echo "поставь плагин av-dev либо задай путь: task openspec OPENSPEC_PY=<путь>"
exit 3
fi
python3 "$py" check --dir .
vulns:
desc: 'Достижимые из кода уязвимости в зависимостях'
cmds:
# `govulncheck` — внешний инструмент, а не плагин и не файл репозитория:
# ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Его
# отсутствие — отказ окружения, код 3, как у прочих обёрток.
#
# Свой код 3 у самого инструмента значит «уязвимость найдена» и с кодом
# обёртки совпадает; различает их сообщение — обёртка называет недостающий
# инструмент. Шагу нужна сеть: база уязвимостей живёт на vuln.go.dev, и без
# сети шаг краснеет, а не пропускается молча.
- |
if ! command -v govulncheck >/dev/null 2>&1; then
echo "govulncheck не найден в PATH"
echo "поставь: go install golang.org/x/vuln/cmd/govulncheck@latest"
exit 3
fi
govulncheck ./...
# Контракт роли app_image (pet-project-server): собрать полный образ и затегать
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
image:
+53
View File
@@ -0,0 +1,53 @@
// Command devtools — оснастка разработчика: то, что нужно для локального
// прогона и никогда не едет в боевой образ.
//
// Пакет один на все такие инструменты, а не по пакету на инструмент. Причина
// счётная: каждый отдельный пакет стоит четырёх мест — строка сборки образа,
// «Деплой» в устройстве, «Команды» в памятке, README, — и забытая строка сборки
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
//
// Подкоманда одна — `resume`, возврат остановленной записи в работу. Она встала
// на место панели владельца: панели у сервиса больше нет, а экраны правки
// записи приносят отдельные задачи. Подставной обратный прокси жил здесь второй
// подкомандой и убран 2026-08-23 задачей `config-test-headers-login`: заголовки
// входа локального прогона подставляет сам сервис по своим настройкам.
//
// Вывод идёт stdlib-логом в поток ошибок, а не `slog`: его читает человек в
// терминале, в сбор он не едет. Изъятие названо строкой в конвенции журнала.
//
// В образ пакет не едет: ступень сборки называет `./cmd/transcriber` поимённо.
package main
import (
"fmt"
"log"
"os"
)
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "resume":
runResume(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprint(os.Stderr, `Оснастка разработчика.
Подкоманды:
resume вернуть остановленную запись в работу
`)
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"flag"
"fmt"
"log"
"os"
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/config"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// stepResume — чем возврат в работу подписывается в журнале событий записи.
const stepResume = "resume"
// runResume возвращает остановленную запись в работу.
//
// Подкоманда встала на место панели владельца: панели у сервиса больше нет, а
// экраны правки записи приносят отдельные задачи. Из всего, что владелец делал
// панелью, отложить до экранов нельзя было одно — возврат остановленной записи.
//
// **Колонок подкоманда не пишет.** Перечень полей, которые возврат обязан
// сбросить — признак остановки, признак захвата и срок его протухания, число
// отказов, паузу и время входа в рубеж, — исполняет домен одним действием.
// Рука, забывшая любое из них, оставила бы запись либо невидимой для захвата,
// либо останавливаемой снова первым же захватом — молча, без единой строки.
//
// Событие журнала записи пишется с происхождением «человек»: иначе запись,
// побывавшая остановленной и вернувшаяся в работу, неотличима в журнале от
// записи, которую конвейер вёл без остановок, а происхождение события перестаёт
// различать что-либо.
func runResume(args []string) {
flags := flag.NewFlagSet("resume", flag.ExitOnError)
configPath := flags.String("c", "config.toml", "путь к файлу настроек")
if err := flags.Parse(args); err != nil {
os.Exit(2)
}
if flags.NArg() != 1 {
fmt.Fprint(os.Stderr, "укажи идентификатор записи: devtools resume [-c config.toml] <id>\n")
os.Exit(2)
}
recordID, ok := ident.Parse(flags.Arg(0))
if !ok {
log.Fatalf("идентификатор записи не читается: %q", flags.Arg(0))
}
cfg, err := config.LoadConfig(*configPath)
if err != nil {
log.Fatalf("настройки не читаются: %v", err)
}
if err := cfg.Storage.Validate(); err != nil {
log.Fatalf("настройки хранилища негодны: %v", err)
}
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
ReadConnections: cfg.Storage.ReadConnections,
})
if err != nil {
log.Fatalf("база не открывается: %v", err)
}
defer func() {
if err := db.Close(); err != nil {
log.Printf("база закрылась с отказом: %v", err)
}
}()
records := sqliterepo.NewAudioRecordRepository(db)
events := sqliterepo.NewRecordEventRepository(db)
record, err := records.Get(recordID)
if err != nil {
log.Fatalf("запись не читается: %v", err)
}
if !record.IsHalted() {
log.Fatalf("запись %s не остановлена: возвращать в работу нечего", recordID)
}
// Захват снимает сам домен, поэтому сохранение идёт **безусловным**: держателя
// у остановленной записи нет, и сверять признак захвата не с чем.
record.Resume()
if err := records.Save(record, ""); err != nil {
log.Fatalf("запись не сохраняется: %v", err)
}
if err := events.Append(&entity.RecordEvent{
RecordID: recordID,
Origin: entity.EventOriginHuman,
Step: stepResume,
Outcome: entity.EventOutcomeResumed,
}); err != nil {
log.Fatalf("событие журнала записи не сохраняется: %v", err)
}
log.Printf("запись %s возвращена в работу с рубежа %s", recordID, record.State)
}
+285
View File
@@ -0,0 +1,285 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service"
"git.vakhrushev.me/av/transcriber/web"
)
// main держит одну обязанность: отказ подъёма пишется **одной** строкой и
// кончается ненулевым кодом выхода.
//
// Работа вынесена в run, чтобы уборка шла отложенными вызовами: `os.Exit`
// посреди подъёма оставил бы за собой открытые пулы базы и незакрытого клиента
// распознавания.
func main() {
// Создаем структурированный логгер
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
if err := run(logger); err != nil {
logger.Error("Transcriber service failed to start", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
// Parse command line flags
configPath := flag.String("c", "config.toml", "Path to config file")
flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)")
flag.Parse()
cfg, err := config.LoadConfig(*configPath)
if err != nil {
return fmt.Errorf("unable to load configuration from %s: %w", *configPath, err)
}
logger.Info("Configuration loaded successfully", "config_path", *configPath)
// Пустой перечень доверенных адресов роняет старт: он значит «не верить
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
// этом было бы неоткуда.
if err := cfg.Auth.Validate(); err != nil {
return err
}
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
// значило бы платить за настройку, которая не меняется.
trustedNetworks, err := cfg.Auth.TrustedNetworks()
if err != nil {
return err
}
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
// доходит вовсе, — а это разные поломки в разных местах.
logger.Info("Trusted proxies configured", "trusted_proxies", cfg.Auth.TrustedProxies)
// Настройки отладочного входа: заполненная имитация без предохранителя, имя
// заголовка, которого сервис не читает, и имитация без годного логина роняют
// старт. Имена заголовков приходят проверке доводом — дом у них один,
// константы транспорта, — а пакет настроек транспорта не знает.
if err := cfg.ValidateTestHeaders(
httpcontroller.IdentityHeaderNames(), httpcontroller.LoginHeader,
); err != nil {
return err
}
// Представление предиката «подставляем ли» одно — непустота перечня, — и
// судят его одинаково строка журнала ниже, слой подстановки и проверка выше.
// Второе выражение того же предиката разошлось бы с первым молча.
substitution := cfg.HeaderSubstitution()
if len(substitution) > 0 {
// Уровень предупреждающий: сервис называет пришедшего сам, никого не
// спросив, — ровно то «может стать проблемой», ради которого заведён
// этот уровень. Идут имена заголовков; значений нет — логин это ключ к
// чужому архиву.
logger.Warn("Identity headers are substituted from configuration",
"headers", httpcontroller.SubstitutedHeaderNames(substitution),
"capability", "access")
}
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
// ней значит остановить всякую запись первым же захватом.
if err := cfg.Pipeline.Validate(); err != nil {
return err
}
if err := cfg.Storage.Validate(); err != nil {
return err
}
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
ReadConnections: cfg.Storage.ReadConnections,
})
if err != nil {
return err
}
defer func() {
if err := db.Close(); err != nil {
logger.Error("Failed to close the database", "error", err)
}
}()
// Создаем контекст для graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Схема накатывается **до** подъёма входов и до старта воркеров, а её отказ
// роняет старт: сервис, поднявшийся на неприведённой схеме, отвечает отказом
// на каждый запрос и на каждый прогон воркера — вместо одной строки о
// причине их становятся сотни.
if err := sqliterepo.Migrate(ctx, db, cfg.Storage.DataDir, logger); err != nil {
return err
}
store := sqliterepo.NewStore(cfg.Storage.DataDir)
recordRepo := sqliterepo.NewAudioRecordRepository(db)
fileRepo := sqliterepo.NewFileRepository(db, store)
repos := service.Repositories{
Records: recordRepo,
Files: fileRepo,
Texts: sqliterepo.NewTextRepository(db),
Structures: sqliterepo.NewStructureRepository(db),
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
Events: sqliterepo.NewRecordEventRepository(db),
}
users := sqliterepo.NewUserRepository(db)
// Создаем адаптеры
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
converter := ffmpegconv.NewFfmpegConverter()
recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{
Region: cfg.Yandex.ObjStorageRegion,
AccessKey: cfg.Yandex.ObjStorageAccessKey,
SecretKey: cfg.Yandex.ObjStorageSecretKey,
BucketName: cfg.Yandex.ObjStorageBucketName,
Endpoint: cfg.Yandex.ObjStorageEndpoint,
ApiKey: cfg.Yandex.SpeechKitAPIKey,
FolderID: cfg.Yandex.FolderID,
})
if err != nil {
return fmt.Errorf("failed to create audio recognizer: %w", err)
}
// Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт
// в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь
// при повторном закрытии, то есть запись говорит о нашей ошибке, а не о
// недоступности Yandex.
defer func() {
if err := recognizer.Close(); err != nil {
logger.Error("failed to close audio recognizer", "error", err)
}
}()
transcribeService := service.NewTranscribeService(
repos,
metaviewer,
converter,
recognizer,
cfg.Pipeline.StuckLimits(),
logger,
)
// Создаем WaitGroup для ожидания завершения всех воркеров
var wg sync.WaitGroup
// Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу
// самой записи. Число приходит настройкой, ноль — законное значение.
pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
wg.Add(1)
go func() {
defer wg.Done()
pool.Start(ctx)
}()
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему.
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
appHandler := httpcontroller.NewAppHandler(
recordRepo, repos.Texts, repos.Structures, fileRepo, transcribeService, logger,
)
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
// наблюдения, правило неизвестного пути у раздачи приложения и область
// действия узнавания.
mounts := httpcontroller.ServiceMounts(
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, substitution, logger),
promhttp.Handler(),
)
dist, appBuilt := web.Dist()
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, logger)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: httpcontroller.BuildHandler(mounts, webappHandler, logger),
// Шесть часов записи по медленному каналу переживают любой фиксированный
// таймаут чтения. Стойкость к целенаправленной нагрузке объявлена вне
// модели угроз проекта.
ReadTimeout: 0,
}
serveErr := make(chan error, 1)
wg.Add(1)
go func() {
defer wg.Done()
logger.Info("Starting HTTP server", "port", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serveErr <- err
}
}()
// Настраиваем обработку сигналов для graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
logger.Info("Transcriber service started", "pipeline_workers", pool.Size())
logger.Info("Press Ctrl+C to stop...")
// Ждем сигнал завершения либо отказ сервера
var startupErr error
select {
case <-sigChan:
logger.Info("Received shutdown signal, initiating graceful shutdown...")
case err := <-serveErr:
logger.Error("HTTP server stopped unexpectedly, shutting down", "error", err)
startupErr = err
}
// Останавливаем HTTP сервер
shutdownCtx, shutdownCancel := context.WithTimeout(
context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer shutdownCancel()
logger.Info("Shutting down HTTP server...")
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("HTTP server forced to shutdown", "error", err)
} else {
logger.Info("HTTP server stopped gracefully")
}
// Отменяем контекст для остановки воркеров
cancel()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
logger.Info("All workers stopped gracefully")
case <-time.After(time.Duration(cfg.Server.ForceShutdownTimeout) * time.Second):
logger.Warn("Timeout reached, forcing shutdown")
}
logger.Info("Transcriber service stopped")
return startupErr
}
-45
View File
@@ -1,45 +0,0 @@
# Server configuration
[server]
port = 8080
shutdown_timeout = 5
force_shutdown_timeout = 20
# Database configuration
[database]
path = "data/transcriber.db"
# File storage configuration
[storage]
path = "data/files"
# Yandex Cloud Configuration
[yandex]
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
folder_id = "your_folder_id_here"
# API ключ для доступа к Yandex SpeechKit (получить в консоли Yandex Cloud)
speech_kit_api_key = "your_speech_kit_api_key_here"
# Object Storage (S3) configuration
# Access Key ID для доступа к Object Storage (получить в консоли Yandex Cloud)
object_storage_access_key_id = "your_access_key_id"
# Secret Access Key для доступа к Object Storage (получить в консоли Yandex Cloud)
object_storage_secret_access_key = "your_secret_access_key"
# Имя бакета в Object Storage
object_storage_bucket_name = "your_bucket_name"
# Регион Object Storage
object_storage_region = "ru-central1"
# Endpoint Object Storage
object_storage_endpoint = "https://storage.yandexcloud.net/"
# Telegram Bot Configuration
[telegram]
# Токен Telegram бота (получить у @BotFather в Telegram)
bot_token = "your_telegram_bot_token_here"
# Таймаут обновлений Telegram бота (в секундах)
update_timeout = 10
+154
View File
@@ -0,0 +1,154 @@
# Server configuration
[server]
port = 8080
shutdown_timeout = 5
force_shutdown_timeout = 20
# Предохранитель отладочного запуска. Значения: false (по умолчанию) и true.
#
# Означает он одно: прогон идёт на машине разработчика, и сервису позволено
# подставить то, что в бою даёт обратный прокси, — заголовки входа из секции
# [auth.test_headers] ниже. Перечня следствий сверх этого у него нет: уровня
# журнала, текстов внутренних отказов, ограничителя частоты и подмены
# распознавателя признак не касается.
#
# Цена включения названа прямо: сервис с true и заполненной имитацией называет
# пришедшего сам, никого не спросив, и отдаёт архив всякому, чей запрос пришёл с
# доверенного адреса. В бою доверенный адрес — это адрес обратного прокси, то
# есть всякий, кто пришёл обычным путём. В боевом файле ключ стоит false.
debug = false
# Хранилище: каталог данных и числа его базы.
#
# Каталог единственный: под ним лежат и файл базы, и подкаталог с файлами
# записей. Двух путей у хранилища не бывает.
[storage]
data_dir = "data"
# Сколько ждать занятую базу, миллисекунды. Положительное число.
#
# База принимает **одного** писателя: драйвер пишет единственным соединением, и
# несколько воркеров, пришедших писать разом, встают в очередь. Это число —
# сколько ждущий готов простоять, прежде чем получить отказ «база занята».
# Крутят его при таком отказе под несколькими воркерами; ноль означает «отказать
# сразу» и потому не принимается.
busy_timeout_ms = 5000
# Сколько соединений держит читающий пул. Положительное число.
#
# Чтение идёт отдельно от записи: в журнале упреждающей записи читатели не
# мешают писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
# Число выводят из числа воркеров плюс запас под запросы приложения.
#
# Пишущее соединение при этом всегда одно и настройкой не делается: второе
# означало бы отказы по занятости на записи результата шага, то есть после
# оплаченной работы.
read_connections = 4
# Конвейер расшифровки.
[pipeline]
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
# работе запись и выбирает шаг по её рубежу.
#
# Ноль — законное значение, а не поломка: сервис поднимается, записи
# принимаются и не двигаются. Годится местному запуску и выкладке, где конвейер
# надо остановить, не роняя приём.
workers = 3
# Предел простоя записи там, где работу делаем мы сами, в минутах.
#
# Сторож ловит **зависание**, а не долгую работу: пока шаг идёт, запись занята
# захватом, и живой процесс наблюдается сам по себе. Час меньше времени, которое
# многочасовая запись занимает на приведении, и это принято сознательно
# (решение владельца 2026-08-14): цена ложной остановки — одно движение
# владельца, потому что остановка обратима и рубежа не стирает.
own_work_limit_minutes = 60
# Предел простоя там, где ждём операцию внешнего сервиса, в минутах.
#
# Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибаемся в
# сторону долгого: ложная остановка хуже поздней. Откладывание опроса этот
# отсчёт не двигает — иначе зависшая у провайдера операция опрашивалась бы
# вечно.
foreign_work_limit_minutes = 1440
# Yandex Cloud Configuration
[yandex]
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
folder_id = "your_folder_id_here"
# API ключ для доступа к Yandex SpeechKit (получить в консоли Yandex Cloud)
speech_kit_api_key = "your_speech_kit_api_key_here"
# Object Storage (S3) configuration
# Access Key ID для доступа к Object Storage (получить в консоли Yandex Cloud)
object_storage_access_key_id = "your_access_key_id"
# Secret Access Key для доступа к Object Storage (получить в консоли Yandex Cloud)
object_storage_secret_access_key = "your_secret_access_key"
# Имя бакета в Object Storage
object_storage_bucket_name = "your_bucket_name"
# Регион Object Storage
object_storage_region = "ru-central1"
# Endpoint Object Storage
object_storage_endpoint = "https://storage.yandexcloud.net/"
# Кому сервис верит на входе.
#
# Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком
# `Remote-User`, сходив к Authelia. Здесь остаётся один ключ — перечень адресов,
# чьему заголовку верить. Пустой перечень роняет старт: он значит «не верить
# никому», то есть сервис, поднявшийся никого не узнающим.
[auth]
# Адреса и подсети, с которых приходит обратный прокси. Сверяется адрес самого
# соединения, а не пересылаемый заголовок: пересылаемым распоряжается тот, кто
# шлёт запрос.
#
# **Перечень задаёт адрес прокси, а не весь частный диапазон.** Всякий, кто
# дотянулся до сервиса с адреса из этого перечня, называет себя кем угодно и
# получает чужой архив; `172.16.0.0/12` означало бы «любой контейнер на хосте»,
# включая чужие проекты. На сервере сюда ставят адрес сети, в которой стоит
# Caddy, — узкий и свой.
trusted_proxies = ["172.20.0.0/24"]
# Локальный вход без Authelia — рецепт целиком.
#
# Прокси на машине разработчика нет, а браузер заголовков не ставит — значит
# приложение локально не открылось бы вовсе. Заголовки входа подставляет сам
# сервис: второго процесса и второго порта для этого не нужно, приложение
# открывают по адресу сервиса.
#
# Три правки этого файла сверху вниз, и других не нужно:
#
# 1. Добавить в перечень выше пару петлевых адресов — обе записи, а не одну:
#
# trusted_proxies = ["172.20.0.0/24", "127.0.0.1", "::1"]
#
# Браузер разрешает localhost в IPv6 не реже, чем в IPv4, и перечень без
# `::1` даёт неузнанный запрос. Отказ подстановки при этом виден строкой
# журнала с адресом пира — по ней и опознаётся недостающая запись.
#
# 2. Поставить в секции [server] выше:
#
# debug = true
#
# 3. Раскомментировать секцию ниже и назвать в ней Remote-User. Ключ —
# имя заголовка, значение — то, чем сервис назовёт пришедшего. Принимаются
# три имени: Remote-User, Remote-Name, Remote-Email; иное роняет старт.
# Ключ Remote-User обязателен: без него сервис подставит всё прочее и не
# узнает никого.
#
# Второй вошедший получается другим значением Remote-User: логин и есть ключ
# учётной записи.
#
# Заполненная секция при debug = false роняет старт с именем ключа
# предохранителя: состояние «имитация есть, предохранителя нет» не читается
# никак, а обе его прочтения — поломка.
#
# [auth.test_headers]
# Remote-User = "local"
# Remote-Name = "Разработчик"
# Remote-Email = "local@example.com"
+4 -4
View File
@@ -12,21 +12,21 @@ if [ "${USER}" != "transcriber" ]; then
fi
if [ -z "${USER_GID}" ]; then
USER_GID="$(id -g ${USER})"
USER_GID="$(id -g "${USER}")"
fi
if [ -z "${USER_UID}" ]; then
USER_UID="$(id -u ${USER})"
USER_UID="$(id -u "${USER}")"
fi
# Change GID for USER?
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g ${USER})" ]; then
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g "${USER}")" ]; then
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group
sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd
fi
# Change UID for USER?
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u ${USER})" ]; then
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u "${USER}")" ]; then
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd
fi
@@ -0,0 +1,49 @@
# ADR-2026-08-11. Границу распознавания доменного признака держит норма, а не код
- **Дата:** 2026-08-11
- **Источник:** [openspec/changes/archive/2026-08-11-errors-as-instead-of-typecast/design.md](../../openspec/changes/archive/2026-08-11-errors-as-instead-of-typecast/design.md), раздел `Decisions`, Решение 3
## Решение
Признак «работы нет» узнаётся через `errors.As`, то есть на любой глубине цепочки
ошибки. Встречный риск — отказ, к которому признак примешался по дороге, — закрыт
**требованием спеки**, а не проверкой в коде воркера.
Дословно из источника:
> Граница ставится **нормой, а не кодом**: спека требует, чтобы признак рождался
> только ответом хранилища на опрос этим же шагом, и запрещает слою сохранять
> чужой признак в цепочке своей ошибки.
## Почему
Приведение типа видело только вершину цепочки — потому и ломалось от первой же
обёртки. `errors.As` эту проблему устраняет, но устраняет симметрично: признак
теперь виден и там, где его никто не клал осознанно. Отказ, к которому признак
примешался обёрткой или `errors.Join`, воркер зачёл бы пустым прогоном — задача
осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи.
Это тот же класс, от которого защищает инвариант «Принятая запись не теряется
молча», только с обратным знаком относительно чинимого дефекта.
Кодовый вариант рассмотрен и отвергнут по цене:
> **Рассмотрено и отвергнуто — научить воркер различать «признак на вершине» от
> «признака в глубине».** Отвергнуто по цене: `errors.As` такого различения не
> даёт вовсе, пришлось бы либо проверять вершину вручную (то есть вернуть
> приведение типа, которое чинится), либо заводить свой обход цепочки. Код
> усложняется ради случая, которого сегодня нет ни одного, а защита от него нужна
> на входе — при написании нового слоя, — где норма работает, а проверка в
> рантайме опоздала бы.
## Последствия
- `+` код остался коротким: одна проверка вместо разбора цепочки вручную.
- `+` защита стоит там, где ошибку совершают, — за письменным столом автора
нового слоя, а не в рантайме, где она уже случилась.
- `` норма не механизирована: её нарушение поймает только ревью или чтение.
Единственный `MUST` требования `pipeline` без машинного оракула — этот.
- `` правило живёт в двух документах: требованием в
[openspec/specs/pipeline/spec.md](../../openspec/specs/pipeline/spec.md) и
прозой в [conventions/errors.md](../conventions/errors.md), где оно нужно
автору в момент письма. Второй адрес ссылается на первый и нормой не является —
разойтись они могут только правкой, сделанной мимо спеки.
@@ -0,0 +1,54 @@
# ADR-2026-08-11. Отказ, который решено не проверять, объявляется поимённо
- **Дата:** 2026-08-11
- **Источник:** [openspec/changes/archive/2026-08-11-errors-as-instead-of-typecast/design.md](../../openspec/changes/archive/2026-08-11-errors-as-instead-of-typecast/design.md), раздел `Decisions`, Решение 2
## Решение
У `errcheck` включена настройка `check-blank`: присваивание отказа в `_` больше
не снимает замечание линтера. Место, где отказ решено не проверять, вносится в
`exclude-functions` поимённо.
Дословно из источника:
> Правило `errcheck` сегодня молчит на `_ = conn.Close()`: настройка
> `check-blank` не выставлена, а её умолчание — «пропускать». То есть
> реализация, выбрасывающая отказ в пустоту, удовлетворяет критерию приёмки
> «линтер не даёт замечаний `errcheck`», не удовлетворяя самому критерию —
> «отказ возвращается либо попадает в журнал».
## Почему
Решение принято не ради строгости, а потому что **оракул не мог упасть**. Задача
`errors-as-instead-of-typecast` закрывала два непроверенных `Close`, и её
критерий приёмки опирался на молчание линтера. Ревью дизайна показало, что этому
критерию удовлетворяет и негодная реализация: `_ = conn.Close()` теряет отказ
целиком, а линтер молчит. Критерий, который нельзя уронить, не проверяет ничего —
и вместе с ним в `CLAUDE.md` снималась запись о долге, то есть сигнал исчез бы
навсегда и без следа.
Очевидный путь был другим и отвергнут намеренно:
> **Рассмотрено и отвергнуто — дописать оба типа в `exclude-functions`
> `.golangci.yml`.** Соблазн сильный: список исключений там уже есть, и в нём
> записана ровно эта политика […] Отвергнуто: политика в конфиге относится к
> закрытию, у которого **отказ ничего не значит** […] Записав их в исключения,
> мы бы расширили политику молча, самим фактом добавления строки, и потеряли бы
> оба сигнала навсегда.
Включение проверено прогоном до правки кода: на тогдашнем коде правило не давало
ни одного нового замечания, то есть включалось чисто и отдельного коммита
приведения не требовало.
## Последствия
- `+` критерий «отказ не теряется молча» стал проверяемым машиной: мутация
(замена обоих мест на `_ = …Close()`) роняет линтер — проверено прогоном.
- `+` умолчание сместилось в сторону заметности: спрятать отказ по месту больше
нельзя, отказ от проверки виден в одном файле списком.
- `` осознанное игнорирование подорожало: вместо одного символа `_` нужна строка
в `exclude-functions` с полным именем метода. Для одноразового случая это
заметная церемония.
- `` список исключений будет расти, и каждая его строка — это политика на весь
проект, а не на одно место. Разрастание списка — сигнал, что правило выбрано
неверно, и повод пересмотреть эту запись.
@@ -0,0 +1,45 @@
# ADR-2026-08-11. Наружу расширение выходит только приведённым к перечню
- **Дата:** 2026-08-11
- **Источник:** [openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md](../../openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md), раздел `Decisions`
## Решение
Расширение принятой записи приводится к закрытому перечню известных форматов
прежде, чем уйти меткой метрики; всё, чего в перечне нет, заменяется одним общим
значением. Имя файла на диске при этом не трогается — там расширение остаётся
тем, каким пришло.
Дословно из источника:
> Из трёх способов человек выбрал средний. Отвергнуты: оставить как есть и завести
> задачу — канал жил бы до неё, а закрытие этой задачи читалось бы как
> «починено»; приводить расширение везде, включая имя файла на диске, —
> раскладка каталога записей объявлена необратимой и меняется решением человека,
> а не по ходу починки журнала.
## Почему
Расширение берётся из имени, которое дал отправитель, дословно: имя
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08`
`08`. Оно уходило меткой метрики, а страница метрик отдаётся без проверки
отправителя. Канал оказался шире того, ради которого задача заводилась: журнал
читает владелец сервиса, метки — кто угодно, и то же значение оседает в
хранилище метрик. Тем же каналом множество значений метки становится
неограниченным: их задаёт анонимный отправитель.
Отказ от нормализации на диске — не экономия, а граница обратимости: формат
имени файла и раскладка `data/files` объявлены необратимыми, и меняются они
решением человека под свою задачу, а не попутно с починкой журнала.
## Цена
- Перечень форматов стал нормой и требует ведения: формат, который сервис
начнёт принимать, до внесения в перечень будет виден в метрике как общее
значение, неотличимо от чужого хвоста.
- Форма метки размера принятой записи изменилась — ведущая точка пропала
(`.mp3` стало `mp3`). Ряды, собранные до выкладки, перестают пополняться.
- Настоящий формат записи, попавшей в общее значение, остаётся видимым только в
журнале — по полю пути строки приёма и полю формата строки конвертации.
- Хвост расширения по-прежнему уходит в журнал внутри пути файла. Это остаток,
он записан в [../security.md](../security.md), и своей задачи у него пока нет.
@@ -0,0 +1,85 @@
# Хранилище, файлы и вход переезжают в PocketBase
- **Дата:** 2026-08-11
- **Источник:** [../research/pocketbase.md](../research/pocketbase.md) — записка
разведки `pocketbase-admin-fit`
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение
PocketBase заменяет SQLite с goqu и goose и берёт на себя три вещи разом:
состояние задач и метаданные, файлы записей своим полем и своей раскладкой на
диске, вход пользователей через Authelia своим провайдером OIDC. Панель
администратора работает по всем трём частям только в таком составе.
## Почему
Разведка мерила панель по трём частям, и порознь ни одна перевода не оправдывала.
Правку записей панель даёт целиком, а две остальные упираются в то, где лежат
данные. Цитата из источника, раздел про пользователей:
> Панель показывает свою коллекцию пользователей и ничего больше. Отсюда
> следствие для целевого входа: **пользователи Authelia в панели не появятся,
> если вход делает само приложение**.
И раздел про файлы:
> Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь она
> покажет строкой — прослушать и скачать запись по ней нельзя. Способа
> сослаться на файл, уже лежащий на диске мимо её каталога, нет.
Там же то, что связывает файлы с сохранностью архива:
> **Резервные копии накрывают ровно её каталог.** Файлы, оставленные снаружи, в
> них не попадут — то есть панель и встроенное резервное копирование покупаются
> одной и той же ценой.
Отвергнуты два половинчатых пути, и оба по одной причине — они покупают перевод,
не покупая того, ради чего он затевался:
> **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
> панель тогда не даёт по файлам ничего, и встроенные копии их не накрывают.
> Довод, ради которого перевод затевался, пропадает целиком.
>
> **Оставить вход у приложения, а PocketBase взять только хранилищем.**
> Отвергнуто: пользователей панель в этом случае не показывает вовсе, и одна из
> трёх частей вопроса остаётся без ответа навсегда, а не до какой-то задачи.
Запись попадает в журнал по двум основаниям сразу. **Откат дорогой:** меняется
раскладка файлов на диске, а она в `../../CLAUDE.md` названа необратимой.
**Пересматривается прежнее решение:** вход через OIDC собирались делать в самом
приложении — так это записано в `../architecture.md`, «Открытые вопросы», и так
поставлена задача `oidc-login`. Парного статуса «заменено на» прежняя запись не
получает: своего ADR у неё нет, решение жило открытым вопросом архитектуры.
## Последствия
- `+` панель даёт владельцу править записи, видеть пользователей и слушать сами
файлы. Проверено на версии 0.39.10; в библиотечной сборке панель отдаётся по
адресу `/_/` того же порта.
- `+` база и записи съезжаются под один каталог, и копия сервера накрывает их
разом. Своё копирование по расписанию с выгрузкой в S3-совместимое хранилище у
PocketBase тоже есть, но копии сервер уже делает своими средствами — берём мы
встроенное или нет, здесь не решено.
- `+` требование CGO уходит: PocketBase ходит в SQLite через
`modernc.org/sqlite`, и пробник собрался при `CGO_ENABLED=0`. Свойство стека в
`../../CLAUDE.md` перестаёт быть верным.
- `` раскладка `data/files` меняется необратимо: файл ложится в
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
переносятся по прежнему решению задачи `pocketbase-storage`.
*Уточнено 2026-08-12:* каталог задаётся ключом `[storage] data_dir` со
значением `data`. Суффикс из десяти знаков дописывает конструктор имени,
которого сервис не зовёт, — имя задаёт он сам. Действующая раскладка —
[../database.md](../database.md), «Представление данных».
- `` вход перестаёт быть нашим: задача `oidc-login` переписывается с
собственной обработки ответа провайдера на настройку провайдера в PocketBase.
Что делать с сессией и где она живёт, решает уже не наш код.
- `` появляется секрет, которого не было: пароль суперпользователя панели. Сама
PocketBase Authelia к панели не подпускает — ни OIDC, ни второй фактор у
коллекции суперпользователей включить не удалось. Своё ограничение по списку
адресов у неё есть, но им же можно запереть себя: сброса в наборе команд нет.
- `` панель висит на том же порту, что и приложение, а порт опубликован в
интернет через обратный прокси. **Закрывает её контур:** тем же решением адрес
`/_/` закрывает Authelia на прокси, пропуская группу администраторов. Приложение
тут ни при чём, и задачи в беклоге у этого нет.
@@ -0,0 +1,67 @@
# Очередь остаётся своей таблицей, но коллекцией PocketBase
- **Дата:** 2026-08-11
- **Источник:** [../research/job-queue.md](../research/job-queue.md) — записка
разведки `job-queue-choice`
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение
Очередь задач остаётся своей таблицей и становится коллекцией PocketBase: захват
идёт одним запросом с `RETURNING`, число попыток лежит колонкой, нарастающая
пауза выражается существующим `delay_time`, а исчерпавшая попытки задача
переходит в состояние «мертва» вместо сегодняшнего `is_error = 1`. Готовую
библиотеку очереди не берём.
## Почему
Разведка искала готовую очередь и нашла, что для PocketBase её нет:
> Единственная очередь в списке — `pocketbase-queue`, написана на TypeScript и
> работает из JS-хуков; из Go её не подключить.
Отсюда разрез, который и решил дело:
> выбор идёт не между готовым и своим, а между **своим в коллекции PocketBase** и
> **чужой очередью, живущей рядом с PocketBase и мимо её панели**. River и goqite
> про PocketBase не знают.
Главный довод в пользу чужой библиотеки — транзакционный захват — снялся
замером:
> движок за `modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём
> есть, и на трёх горутинах разом запись получила **ровно одна**. Это снимает
> главный довод в пользу чужой библиотеки: транзакционность захвата покупается
> одной строкой запроса, а не новой зависимостью.
Отвергнуты два кандидата, и оба с названной ценой:
> **River с драйвером SQLite** — покупает повторы, счётчик и мёртвых готовыми, но
> выносит очередь из панели PocketBase, ради которой хранилище и переезжает, и
> переписывает конвейер в цепочку задач.
>
> **goqite** — не отвечает ни на один из трёх вопросов задачи целиком, а его
> предел выдач молча теряет запись.
Запись попадает в журнал как **намеренный отказ от очевидного подхода**: взять
готовую библиотеку вместо своего кода — первое, что предлагают на такой вопрос, и
без записанной причины его предложат снова. Принцип «очередь таблицей» из
[../architecture.md](../architecture.md) этим решением подтверждён, а не
пересмотрен, поэтому парного статуса «заменено на» никакая запись не получает.
## Последствия
- `+` очередь видна и правится в панели администратора: мёртвая задача повторяется
снятием состояния, а не запросом в консоли сервера. Ровно за это и куплен
перевод хранилища на PocketBase
([ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)).
- `+` ни одной новой зависимости: River добавил бы 46 пакетов в сборку, goqite — 3.
- `+` захват перестаёт быть двумя запросами без транзакции, и это перестаёт быть
местом, которое держится на том, что три воркера читают три разных состояния.
- `` повторы, счётчик попыток и очередь мёртвых пишем сами, и корректность
захвата наша. Проверяется это тестами задачи `pocketbase-storage`, а не
чужим набором проверок.
- `` захват идёт сырым запросом мимо записей PocketBase: хуки коллекции на нём
не срабатывают, и поле времени изменения проставляет наш код.
- `` приборной панели очереди — числа ждущих, упавших, среднего времени — не
появляется. Смотрим таблицу коллекции в панели PocketBase, отбирая фильтром.
+76
View File
@@ -0,0 +1,76 @@
# Приложение пишем на Vue, а Node входит в гейт и в образ
- **Дата:** 2026-08-11
- **Источник:** [../research/spa-framework.md](../research/spa-framework.md) —
записка разведки `spa-framework-choice`
## Решение
Приложение пишем на **Vue 3** с роутером пятой версии и собираем **Vite** в
статику, которую бинарник вшивает через `go:embed` и раздаёт сам. Маршруты
задаём своей таблицей через `createRouter`; сборочную надстройку роутера под
маршруты по файлам не включаем.
Вместе с этим в проект входит **шаг сборки статики**: Node и `npm` становятся
нужны на машине разработчика, отдельным шагом в `task gate` и слоем сборки в
`Dockerfile`.
Это два решения, а не одно, но принимаются они вместе: шаг сборки появляется при
любом из трёх кандидатов, и отдельно от выбора фреймворка его обсуждать не о чем.
## Почему Vue
Разведка мерила три кандидата на одном и том же экране и нашла единственное
различие, которое расходится в разы:
> Различает единственное — **размер того, что скачивает телефон**, и он
> расходится вчетверо.
Вшивание в бинарник, цена шага сборки в гейте и установка на телефон у всех трёх
оказались одинаковыми и потому ничего не решают.
По размеру Vue стоит посередине — 33 326 Б на четыре экрана против 17 314 у
Svelte и 72 402 у React. Выбран он не по этому числу, а по устойчивости
экосистемы, и оба отвергнутых кандидата отвергнуты с названной ценой:
> **Svelte** — легче Vue вдвое, но своего роутера не имеет, а тот, что есть,
> держит один человек. Владелец выбрал экосистему, которая переживёт проект, а не
> минимальный размер: 33 КБ на телефоне не отличаются от 17 КБ на глаз, а
> брошенная зависимость отличается.
>
> **React** — вчетверо тяжелее Svelte и вдвое тяжелее Vue, а взамен даёт
> экосистему, которой приложению на четыре экрана не на что потратиться.
Роутер берём пятой версии, а не четвёртой, по тому же доводу: она стабильна с
29 января 2026 и несёт метку `latest`, то есть чинить будут её, а не
предшественницу. Её сборочная надстройка добавляет 34 пакета в установку, и это
принятая цена; на собранный файл она не влияет и необязательна.
## Почему это ADR
Запись проходит триггер **дорогим откатом**: переход на другой фреймворк
переписывает все экраны разом, а не один файл. Шаг сборки сюда же — он меняет
требования к машине разработчика и к образу, и снять его потом можно только
вместе с приложением.
Прежнего решения запись не пересматривает: htmx был снят решением о SPA от
2026-08-10, до заведения этого журнала, и парного статуса «заменено на» ставить
нечему.
## Последствия
- `+` разметка отделена от кода однофайловым компонентом, а роутер и хранилище
состояния идут из тех же рук, что и сам фреймворк: третьей библиотеки под них
заводить не нужно.
- `+` собранная статика — три файла и значок, поэтому `go:embed` берёт каталог
обычной строкой, а бинарник остаётся самодостаточным.
- `` **гейт перестаёт зависеть только от Go.** Красный шаг сборки статики
становится таким же поводом остановиться, как красный `go build`, а машина
разработчика получает второе требуемое окружение сверх `ffmpeg`.
- `` **в образ добавляется слой Node** ради шага, результат которого — три
файла; насколько дольше собирается образ и насколько тяжелеет, не замерялось.
- `` в проект приходит `node_modules` на 92 МБ и 84 пакета, за которыми надо
следить отдельно от зависимостей Go: `gitleaks` и `golangci-lint` про них
ничего не знают.
- `` приложение весит 33 КБ там, где на Svelte весило бы 17. Разница куплена
сознательно и обратно не отыгрывается.
@@ -0,0 +1,51 @@
# Проверки не зовут внешних программ
- **Дата:** 2026-08-11
- **Источник:** openspec/changes/archive/2026-08-11-fix-http-handler-tests/design.md
## Решение
Тесты приёма получают длительность записи от подставного источника метаданных, а
не от `ffprobe`. Годность содержимого судит адаптер, тест судит наш код.
## Почему
Цитата из источника, раздел `Decisions`:
> **проверять приём сквозь настоящий `ffprobe`.** Отвергнуто: это проверка
> внешней программы, а не нашего кода. Она найдёт отказ `ffprobe` и не найдёт
> ошибку в приёме — ровно наоборот тому, зачем эти тесты писались.
Отвергнуты там же два очевидных пути, и оба по записанным правилам проекта, а не
по вкусу:
> **положить настоящую запись в `testdata`.** Отвергнуто дважды: `.gitignore`
> строкой `*.m4a` её не пустит, а `CLAUDE.md` прямо говорит, что `testdata` в
> проекте нет и тесты создают нужное во временном каталоге. Снимать запрет ради
> теста — менять правило проекта под удобство одного файла;
>
> **порождать запись `ffmpeg` прямо в тесте.** Отвергнуто: проверка приёма
> начинает требовать установленных `ffmpeg` и `ffprobe`, а критерий приёмки
> требует обратного — прогона с `ffprobe`, убранным из `PATH`.
Решение попадает в журнал как **намеренный отказ от очевидного подхода**: файл с
настоящей записью в `testdata` — первое, что сделал бы человек, и отказ от него
из кода не виден.
## Последствия
- `+` прогон проверок на чистом клоне зелёный без подготовки файлов руками и без
установленных внешних программ. Проверено сборкой тестового бинарника и
прогоном под `env -i PATH=<пустой каталог>`.
- `+` ветка отказа чтения метаданных впервые проверяема: подставной источник
умеет вернуть ошибку, настоящий `ffprobe` по заказу не отказывает.
- `+` проверки не держат состояния процесса: каталог хранения задаётся снаружи,
`os.Chdir` ушёл, и параллельный прогон перестал быть запрещённым.
- `` разбор вывода настоящего `ffprobe` не проверяется ничем: своего теста у
`internal/adapter/metaviewer/ffmpeg` нет. Формально покрытие не потеряно —
прежние проверки звали его так, что он всегда отказывал, — но дыра теперь
наша и записана в [../review.md](../review.md), «Перестали проверять
сознательно».
- `` правило распространяется на будущие проверки: узел, чья работа и есть
обращение к внешней программе, придётся проверять иначе, и чем — здесь не
решено.
@@ -0,0 +1,47 @@
# Кого пускать в сервис, решает правило провайдера, а не сервис
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Кого пускать, решает провайдер, а не сервис»
## Решение
Сервис пускает всякого, кого пропустил провайдер, и **своей проверки допуска не
делает**. Кто допущен, определяет правило Authelia на этого клиента — настройка
выкладки, лежащая вне репозитория.
## Почему
Authelia — общий провайдер контура, а не выделенный под этот сервис: учётная
запись в ней есть у всякого, кому её завели ради любого другого сервиса на том же
сервере. Ревью дизайна назвало следствие прямо: механизм, приглашающий «второго
человека», приглашает всех, кто уже есть у провайдера.
Очевидный ответ — проверять принадлежность к названной в конфиге группе своим
кодом. Владелец от него отказался: это завело бы **второе место**, где решается
допуск, и решать его пришлось бы в двух местах согласованно.
Цена отказа названа в источнике и повторена в модели угроз:
> Правило живёт вне репозитория, в настройках выкладки, и сервис на него
> полагается так же, как полагается на обратный прокси в части панели
> администратора. Настроенный слишком широко клиент открывает сервис всем, у кого
> есть учётная запись в общей Authelia, — и проверить это по коду нельзя.
Запись заводится как **намеренный отказ от очевидного подхода**: проверку группы
предложат снова, и без записанной причины она выглядит бесплатной.
## Последствия
- `+` допуск решается в одном месте, а не в двух; изменение круга допущенных не
требует ни правки кода, ни выкладки.
- `+` сервис не читает из ответа провайдера ничего сверх нужного для заведения
записи — ни групп, ни ролей.
- `` защита сервиса стала свойством настройки, лежащей в другом репозитории, и
ревью её проверить не может: ни один проход не увидит, что клиент настроен
слишком широко.
- `` ошибка в настройке клиента не имеет наблюдаемого признака внутри сервиса:
посторонний, которого пропустила Authelia, выглядит как законный пользователь.
- `` разграничения по владельцу нет, поэтому цена ошибки в настройке — все
записи и все расшифровки разом, а не одна учётная запись. Сузит это
`record-ownership`.
@@ -0,0 +1,64 @@
# Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
- **Статус:** заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md)
## Решение
Поле файла в хранилище **не помечается защищённым**: ссылка
`/api/files/<коллекция>/<запись>/<имя>` работает без токена, и право пройти по
ней даёт знание самой ссылки. Взамен имя файла в хранилище **не пишется в журнал
ни в каком виде** — ни на успешном пути, ни в тексте отказа.
## Почему
Очевидный подход к файлам, отдаваемым в интернет, — закрыть их: у хранилища для
этого есть пометка «защищённое поле», и тогда файл отдаётся только по отдельному
файловому токену. Мы от неё отказываемся, и цитата из источника называет причину:
> Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
> прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
> `GET /api/status/:id`, а не ниже.
Отказ дешёв ровно до тех пор, пока ссылку неоткуда взять. Перевод хранилища это
условие сломал, и вот чем:
> Изъятие выписано под путь на диске: `data/files/<uuid>.ogg` читателю журнала
> бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки
> `/api/files/...`, по которой запись скачивает кто угодно; строка журнала стала
> бы бессрочным ключом к чужому аудио.
Путь был построен ревью и прогнан: отказ чтения из хранилища нёс ключ файла
целиком, строка уходила в журнал, а анонимный запрос по собранному адресу
отвечал `200` с телом записи. Второй путь шёл через отказ выгрузки в Object
Storage — тот несёт полный URL объекта.
Отсюда вторая половина решения, без которой первая недопустима: **отказы
обрываются**. Наружу идёт свой текст с идентификатором записи, а чужая цепочка
`%w` — нет. В журнал приёма вместо имени идёт расширение собственным полем;
прослеживаемость от этого не страдает.
Запись попадает в журнал как **намеренный отказ от очевидного подхода**: закрыть
файлы токеном предложат снова, и без записанной причины предложение выглядит
бесплатным.
## Последствия
- `+` ссылка работает без токена, и приёмка проверяется обычным запросом; будущее
приложение получает файл без отдельного механизма выдачи токенов.
- `+` изъятие из инварианта приватности не расширилось: в журнале по-прежнему
только расширение, а не имя.
- `` ссылка, единожды утёкшая, работает бессрочно: отзыва у неё нет, а файлы не
удаляются вовсе. Утечка возможна не только журналом — любой будущий экран,
показывающий ссылку, наследует это свойство.
- `` появилась норма, которую держит не построение, а внимание: всякий новый
отказ хранилища надо обрывать руками. Норму сторожат требование capability
`storage` и проверка журнала, но компилятор — нет.
- `` диагностируемость отказов упала: обрывая цепочку, мы теряем причину. У
выгрузки в Object Storage это смягчено — сохраняется класс отказа SDK
(`AccessDenied`, `NoSuchBucket`), в котором адреса не бывает.
- Решение действует до разграничения доступа: задачи `oidc-login` и
`record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
записью.
@@ -0,0 +1,52 @@
# Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
## Решение
Обработчик возврата от провайдера зовёт **собственный адрес хранилища**
`auth-with-oauth2` внутри процесса, через его же роутер, а не по сети и не
разбирая ответ провайдера своими руками.
## Почему
Решение [ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
отдало разбор ответа провайдера хранилищу: только тогда учётные записи заводятся
сами и видны в панели. Это решение не пересматривается — пересматривается способ
до него дотянуться.
Проверка исходников библиотеки версии 0.39.10 показала, что обмен наружу не
экспортирован: он живёт неэкспортированной функцией за собственным маршрутом.
Остались три формы, и владелец выбрал первую:
> (а) внутрипроцессный вызов собственного маршрута `auth-with-oauth2`: решение
> 2026-08-11 соблюдено дословно, цена — петля «наш обработчик → наш роутер → наш
> обработчик», разбор JSON-ответа и потеря типизированной ошибки; (б) сборка
> обмена из экспортированных кусков с сохранением записи и связи через `app.Save`:
> прямой код без петли, цена — пересмотр решения 2026-08-11 отдельным ADR; (в)
> отложить вход до появления фронтенда.
Запись заводится как **намеренный отказ от очевидного подхода**: собрать обмен
своими руками выглядит проще и дешевле, и предложение вернётся, если причина не
записана.
## Последствия
- `+` разбор ответа провайдера, заведение учётной записи и связь её с внешним
провайдером остаются за хранилищем — решение 2026-08-11 соблюдено дословно, а
не «по духу».
- `+` наш код не знает ни одного поля ответа провайдера: обновление библиотеки
под смену формата ответа доезжает само.
- `` петля через собственный роутер: обработчик зовёт сервис, частью которого
сам является. Это новый для проекта вид узла, и его придётся объяснять на
каждом следующем изменении.
- `` ответ разбирается текстом, типизированная ошибка теряется: причина отказа
обмена доступна только кодом состояния.
- `` роутер хранилища пришлось собирать **один раз** и держать полем: его
сборка вешает обработчики на само приложение и без идентификатора, поэтому
повторная не заменяет прежние. Ревью кода нашло это построенным путём —
анонимный запрос копил обработчики без предела, а каждое сохранение задачи
конвейером проходило по всем накопленным.
@@ -0,0 +1,57 @@
# Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 3
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение
Поле файла в хранилище **помечается защищённым**, а правило просмотра коллекции
файлов пускает всякого узнанного. Ссылка `/api/files/<коллекция>/<запись>/<имя>`
перестаёт быть правом пройти по ней: нужен короткий токен файла, который берут,
предъявив сессию.
Запись заменяет [ADR-2026-08-12-file-link-open-but-not-logged](ADR-2026-08-12-file-link-open-but-not-logged.md).
## Почему
Прежнее решение было обусловленным и само назвало условие своего пересмотра:
> Решение действует до разграничения доступа: задачи `oidc-login` и
> `record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
> записью.
Условие наступило. Прежний довод — «право прочитать задачу даёт знание её
идентификатора, и файл встаёт вровень с `GET /api/status/:id`» — держался на том,
что опрос готовности открыт анонимно. Этот change закрывает опрос за вход, и
файл, оставшийся открытым, стал бы единственным анонимным путём к содержимому
записи — самому чувствительному, что есть у проекта.
Вторая половина прежнего решения остаётся в силе: имя файла в журнал по-прежнему
не пишется. Защищённое поле сужает право пройти, но не отменяет запрета —
строка журнала со ссылкой собирала бы половину ключа.
Пометки самой по себе оказалось мало, и это выяснило ревью кода прогоном:
защищённый файл судится **и** токеном, **и** правилом просмотра коллекции, а
незаданное правило означает «только владелец панели». Файл не получал ни аноним,
ни вошедший — сценарий спеки не исполнялся вовсе. Правило назначено тем же шагом
схемы.
## Последствия
- `+` содержимое записи перестало быть доступным по одному знанию ссылки; после
закрытия API это был последний анонимный путь к нему.
- `+` условие, названное прежней записью, отработало как задумано: решение
пересмотрено записью, а не молча.
- `` ссылка усложнилась для потребителя: браузер с одной кукой файла не
получает, нужен порядок «сессия → токен файла → ссылка». Будущее приложение
обязано этот шаг делать, и задача про прослушивание записи начинается с него.
- `` разграничения по владельцу нет: токен файла берёт всякий вошедший, и по
ссылке он получит **любую** запись, а не только свою. Сужение приносит
`record-ownership`; до неё круг сузился с «кто угодно из интернета» до «кто
угодно из вошедших», и это меньше, чем кажется.
- `` отзыва у выданного токена нет, как не было у ссылки; смягчает только его
короткий срок.
@@ -0,0 +1,56 @@
# Сессия живёт семь суток и не продлевает саму себя
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 6
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
## Решение
Срок жизни сессии — **семь суток**, назначается при каждом подъёме сервиса.
Продление сессии **выключено**: адрес, которым хранилище меняет предъявленное
значение на новое, закрыт слоем приложения.
## Почему
Умолчание хранилища — пять суток и продлеваемая сессия. Второе делает первое
бессмысленным, и это выяснило ревью кода замером: предъявитель одного живого
значения продлевает себе доступ бессрочно, никуда не входя.
Значение имеет то, на чём держится вся остановка перерасхода. Паспорт опирается
на **отзыв доступа в Authelia** как на способ остановить того, кто тратит слишком
много. Но сервис после входа к провайдеру не обращается: подпись сессии считается
от значений в базе, и отзыв у провайдера до сервиса доходит **только** истечением
срока. При живом продлении не доходит никогда — человек, которому закрыли доступ,
сохраняет его навсегда.
Отвергнуто и названо ценой:
> сверяться с провайдером по расписанию — новая связь с Authelia и обработка её
> недоступности, работа шире задачи; принять как есть — тогда паспорт теряет
> способ остановить того, кто тратит слишком много.
Число семь суток выбрано владельцем как компромисс: реже входить против дольше
ждать, пока отзыв доедет.
Срок назначается **при подъёме, а не шагом схемы**, и это отдельное решение с
причиной: применённый шаг не переписывается, поэтому число, положенное туда,
разошлось бы со сроком жизни куки при первой же правке — браузер получил бы
новый срок, а хранилище продолжило выдавать прежний.
## Последствия
- `+` отзыв доступа у провайдера доходит до сервиса гарантированно, максимум за
семь суток; без этого он не доходил вовсе.
- `+` срок жизни сессии стал числом, которое кто-то выбрал, и правится он в одном
месте вместе со сроком куки.
- `` человек перевходит раз в неделю, и это заметно: своей страницы у сервиса
нет, так что вход начинается с перехода по адресу входа руками.
- `` семь суток — всё ещё окно, в которое отозванный доступ работает. Немедленно
закрыть чужую сессию можно только руками в панели, обновив ключ токенов записи;
своего адреса у этого нет.
- `` закрытие продления сделано слоем приложения, а не настройкой коллекции:
библиотека выдаёт сессию продлеваемой всегда, и отключить это в ней нечем.
Слой придётся помнить при всякой правке маршрутов.
@@ -0,0 +1,50 @@
# Каталог данных задаётся одним ключом `[storage] data_dir`
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Ключи конфигурации: два пути заменяются одним каталогом»
## Решение
Ключи `[database] path` и `[storage] path` уходят. Вместо них — один
`[storage] data_dir` со значением `data`: база и файлы записей лежат под одним
каталогом, и по-другому хранилище не умеет.
Выбор сделан человеком 2026-08-12 из трёх названных вариантов.
## Почему
Имя ключа конфигурации проект объявил необратимым
([../../CLAUDE.md](../../CLAUDE.md), «Работа»): переименование правится не в
одном файле, а в конфигурации на сервере и в выкладке, и молча ломает запуск.
Поэтому выбор ушёл человеку, а не был принят по ходу.
Цитата источника о цене каждого варианта:
> - `[storage] data_dir` — **выбрано**. Ключ назван по назначению, как названы и
> сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
> этом уже занято capability, но в конфигурации оно значит ровно то же — где
> лежат данные;
> - `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
> вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует
> второго необратимого переименования;
> - `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
> каталог на диске, и секция с таким именем читается как «настройки каталога»,
> а не «настройки хранилища».
Запись попадает в журнал по **дорогому откату**: переименование ключа стоит
правки конфигурации на сервере и в выкладке, а ошибка проявляется отказом старта.
## Последствия
- `+` имя ключа не называет поставщика, и смена библиотеки хранилища второго
необратимого переименования не потребует.
- `+` каталог данных один, и запрет «боевой каталог не трогать» покрывает и базу,
и записи одной строкой.
- `` слово `storage` в проекте теперь значит три вещи: capability, само
хранилище и секцию конфигурации. Поле записи о файле от этого переименовано в
`location` — чтобы смыслов было три, а не четыре.
- `` прежние конфигурации несовместимы: сервис на старом `config.toml`
поднимется на умолчании `data`, а не на прежних путях. Данные при этом не
переносятся по решению задачи, так что цена нулевая ровно сейчас и была бы не
нулевой при переносе.
@@ -0,0 +1,66 @@
# ADR-2026-08-12. Спекой нормируется и инструмент сборки, а не только поведение сервиса
- **Дата:** 2026-08-12
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решение 2
- **Статус:** устарело — 2026-08-13 владелец решил обратное: инструментарию в
спеках не место. Capability `toolchain` упразднена, замены у неё нет, а норма
шага осталась комментариями в `scripts/check-go-version.sh`
## Решение
Заведена capability `toolchain` — четвёртая, и первая, которая описывает **не
поведение сервиса** для его потребителей, а поведение инструмента, которым сервис
собирают. Потребитель у неё другой: тот, кто собирает.
Требование о согласованности объявленной версии Go живёт нормой в
`openspec/specs/toolchain/spec.md`, а не прозой в памятке. *Уточнено 2026-08-13:
файла по этому адресу больше нет, ссылка снята — capability упразднена, см.
статус записи.*
## Почему
Три существующие capability — `intake`, `pipeline`, `storage` — все про то, что
сервис делает для своих потребителей, а преамбула `architecture.md` прямо
говорила «поведение системы здесь не описывается — нормативно оно живёт в
`openspec/specs/`». Согласованность версий сборки под это определение не
подходит, и натяжение признано прямо в источнике:
> Признаём натяжение: три существующие capability описывают поведение сервиса для
> его потребителей, а `toolchain` описывает поведение инструмента разработки.
> Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml`
> говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно
> как домен он здесь и назван.
Очевидные пути отвергнуты оба:
> **Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и
> захват задачи — поведение работающего сервиса. Версия сборщика с ним не
> меняется вместе.
>
> **Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое
> требование — «расхождение роняет набор проверок», — и требование без дома
> проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже
> один раз жило в трёх документах и во всех трёх было неверным.
Последнее и есть довод, перевесивший чистоту определения: дефект 2026-08-12
случился именно потому, что утверждение о версии сборки жило только прозой, в
трёх местах сразу, и никто не отвечал за его истинность.
## Последствия
- `+` у правила о версиях есть нормативный дом со сценариями, и по нему видно, что
проверено, а что оставлено человеку. Три требования, двадцать два сценария.
- `+` следующая задача про инструмент сборки знает, куда дописывать, и не заводит
вторую спеку о том же.
- `` определение capability в проекте стало шире, чем «поведение сервиса», и
граница теперь проходит по слову «домен». Следующее пограничное решение будет
ссылаться на этот прецедент — в том числе тогда, когда ссылаться не стоило бы.
- `` асимметрия: четыре однородных шага гейта живут в двух разных домах. У трёх
плагинных (`docs.py`, `tasks.py`, `openspec.py`) нормативного дома нет вовсе,
только строка в памятке; у четвёртого есть спека. Либо дома появятся у
остальных, либо асимметрия останется навсегда.
- `` имя `toolchain` выбрано в том числе из-за настройки среды разработчика:
первая редакция звалась `build`, и глобальный запрет чтения каталогов с таким
именем сделал спеку нечитаемой для проходов ревью. Имя, выбранное под
ограничение инструмента, а не под предмет, — слабое основание, и при следующем
пересмотре его стоит перепроверить.
@@ -0,0 +1,60 @@
# ADR-2026-08-12. Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента
- **Дата:** 2026-08-12
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решения 1 и 6
## Решение
Шаг сверки версий добывает числа чтением файлов и **не зовёт `go` ни в каком
виде** — ни `go mod edit -json`, ни `go list -m`, ни `go env`. Директива
`toolchain` в `go.mod` при этом запрещена: её наличие роняет шаг.
Дословно из источника:
> Способ это не самый удобный: разбор директивы через `go mod edit -json` короче
> и надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой
> `GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN`
> `go` вправе полезть в сеть за нужной версией — то есть требование «без сети»
> перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а
> не от коммита.
## Почему
Причина не в аккуратности, а в том, что **ровно этой подменой и держался дефект,
ради которого шаг заведён**. 2026-08-12 требование модуля уехало на 1.25,
сборочный образ остался на 1.24, образ перестал собираться — и восемь шагов гейта
с шестью проходами ревью показали зелёное, потому что `go build ./...` шёл на
хостовом Go. Проверка судила по тому, что стоит на машине, вместо того что
записано в коммите. Шаг, зовущий `go`, воспроизвёл бы ту же подмену внутри себя:
зелёный там, где стоит нужная версия, и другой ответ на другой машине.
Отсюда же запрет `toolchain`. Директива — штатный механизм Go и очевидное
решение задачи расхождения: она заставила бы Go скачать нужную версию самому, и
сверять стало бы нечего. Отвергнута намеренно:
> Директива `toolchain` заставила бы Go скачивать нужный тулчейн сам, и
> расхождение с образом перестало бы ломать сборку. Но она же превращает сборку
> образа в сетевую операцию, а сборочный слой качает тулчейн при каждой сборке.
> Дороже и менее предсказуемо, чем строка сравнения.
Вдобавок она вводит **пятое место**, называющее версию, — то, которого закрытый
перечень из четырёх мест не знает: при `toolchain go1.27.0` четыре объявленных
числа сойдутся, а собирать будет пятое.
## Последствия
- `+` исход шага есть функция коммита. Проверено прогоном: с `PATH`, где нет
`go`, шаг даёт тот же код выхода и тот же вывод.
- `+` требование «без сети» выполняется по построению, а не обещанием: под
`strace` шаг не делает ни одного сетевого вызова.
- `+` пятое место закрыто: `toolchain` в `go.mod` роняет шаг с названной
причиной.
- `` разбор держится на регулярных выражениях `sed`/`awk` вместо готового
разбора, который дал бы сам `go`. Это дороже в сопровождении и хрупче: правка
образца ломает смежный случай беззвучно.
- `` запрет `toolchain` придётся снять или пересмотреть, если зависимость
однажды потребует версию выше той, что стоит у нас. Тогда эта запись
пересматривается, а не обходится.
- `` проверять сам скрипт нечем: `shellcheck` в гейт не заведён, тестов у него
нет. Из девятнадцати сценариев нормы машина гоняет один — тот, где всё
сошлось. Остаток объявлен и уехал отдельной задачей.
@@ -0,0 +1,67 @@
# Намерение объявляется признаком, а не выводится из ключа доступа
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
## Решение
Вход Telegram включается отдельным признаком `telegram.enabled`, а `bot_token`
означает только доступ. Признак **обязателен**: умолчания у него нет, и файл
настроек без него негоден — сервис выходит с ошибкой настройки, назвав
недостающий ключ.
## Почему
Прежде пустой ключ доступа значил разом две вещи — «вход выключен намеренно» и
«ключа нет», — и сервис поднимался без бота в обоих случаях. Цена расхождения
падала на выкладку: файл настроек собирает Ansible, и потерянный при сборке ключ
выглядел для сервиса как решение владельца.
Умолчания у признака нет, и это **намеренный отказ от очевидного подхода**
булев ключ обычно заводят с умолчанием. Цитата из источника:
> умолчание — это угаданное намерение, а признак заводится ровно затем, чтобы
> намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе
> стороны, и любое умолчание делает одну из двух ошибок тихой.
Отвергнуты оба умолчания. «Включён» — файл без признака работал бы «как-нибудь»,
и разница между объявленным и угаданным намерением исчезала бы ровно там, где её
завели. «Выключен» — первый же подъём после выкладки выключил бы бота молча, то
есть дал бы исход, против которого написано само требование.
Отсутствие ключа судит **разбор**, а не значение: `toml.MetaData.IsDefined`
отличает «не задан» от «задан ложным», тогда как нулевое значение `bool` у обоих
одинаковое. Форма поля с указателем отвергнута: указатель пережил бы проверку и
уехал к потребителям, где `nil` уже невозможен, но выглядит возможным.
Тем же решением закрыт разрез текста отказа при разборе файла настроек. Цитата
из источника:
> Пересказывать библиотеку нельзя: она собирает текст отказа из разбираемого
> куска файла, и оборванная строка секретного ключа уехала бы в журнал вместе со
> значением.
Норму держит инвариант «Секрет не покидает конфиг», а форму записи — конвенция
настроек. Спеки загрузку настроек не нормируют, и это назначено явно: загрузка
не принадлежит ни одной заведённой capability.
## Последствия
- `+` потерянный при сборке файла ключ доступа роняет старт вслух, а не оставляет
сервис работать в половину силы;
- `+` выключенный вход перестал быть поводом для предупреждения: решение
владельца сообщается записью «к сведению», а предупреждение осталось за тем,
чего владелец не выбирал, — недоступностью Telegram;
- `+` оборванная строка секретного ключа больше не уносит значение в журнал
контейнера;
- `` **порядок выкладки стал обязательным**: шаблон настроек обязан получить
признак раньше накатки образа, иначе сервис не поднимется вовсе. Правило живёт
в [architecture.md](../architecture.md), раздел «Эксплуатация», и задаётся там
по ключу, а не по файлу целиком;
- `` один путь молчаливой потери бота остался: признак, ошибочно собранный
как «выключен», отличим от решения владельца только записью журнала. Признак
поднятости входа тут не помощник — он равен нулю и при недоступности Telegram;
- `` отказ разбора файла настроек стал беднее на текст библиотеки: место и ключ
названы, а что именно в строке не так — нет. Плата принята ради инварианта,
помеченного необратимым.
@@ -0,0 +1,67 @@
# Недоступность Telegram подъёму сервиса не мешает
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
## Решение
Старт роняет только один исход сборки клиента бота — ответ Telegram «такого бота
нет». Всё прочее, включая недоступность Telegram и истёкший срок ожидания, даёт
подъём без Telegram: сервис работает по HTTP и говорит о неподнятом входе
записью журнала и метрикой.
## Почему
Очевидный подход был обратный, и он же стоял в первой редакции дизайна: любой
отказ сборки бота роняет старт, потому что «сервис, молча потерявший бота после
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
бы неоткуда».
Ревью кода показало цену этого подхода. Цитата из источника:
> при `api.telegram.org`, отвечающем молчанием, процесс висит в `getMe` без
> ограничения времени: HTTP-вход не открыт, панель не открыта, `/health` не
> отвечает вовсе, воркеры не запущены, в журнале — ни строки.
То есть перезапуск в минуту чужой аварии оставлял без работы приём по HTTP,
панель и конвейер, которому Telegram не нужен вовсе. Паспорт при этом называет
основным входом приложение, а бот и HTTP API — дополняющими его.
Тем же ревью снят довод, на котором держалась прежняя редакция. Она утверждала,
что «Telegram не признал бота» и «до Telegram не дошли» различать нечем. Цитата
из источника:
> Различать есть чем: ответ Bot API приезжает своим типом с кодом, транспортный
> отказ — нашим после чистки, и одно от другого отделяется проверкой типа.
> Утверждение держалось на незнании библиотеки, а не на её устройстве.
Решение владельца: недоступность Telegram на старт приложения не влияет.
Из него следует второе, без которого оно невыполнимо: ожидание при сборке
ограничено сроком. Пока срока не было, недоступность не отличалась от подъёма.
Срок стоит только на сборке — длинный опрос им не ограничен, иначе он рвался бы
на каждом круге.
## Последствия
- `+` авария Telegram не роняет основной вход, панель и конвейер: сервис
поднимается и обрабатывает уже принятое;
- `+` опечатка в токене по-прежнему заметна: Telegram отвечает отказом, и старт
не проходит;
- `+` молчащий Telegram больше не вешает подъём бессрочно;
- `` долгая недоступность Telegram даёт сервис, работающий без бота, а
отправители в это время не получают ответов. Замена «узнать неоткуда» —
запись журнала при старте и признак поднятости входа метрикой;
- `` токен, не разбирающийся как часть адреса (перенос строки из шаблона
выкладки), Telegram не отвергает — его отвергает разбор адреса, и такой случай
попадает в недоступность, а не в ошибку настройки. Заметен он записью журнала,
а не отказом старта.
*Уточнено 2026-08-13:* исходов сборки клиента, роняющих старт, стало два —
к ответу «такого бота нет» добавился пустой ключ доступа при включённом входе.
Решение это не меняет: пустой ключ ошибкой настройки и был, просто прежде он
выражал ещё и отказ от входа, а теперь отказ выражает признак `telegram.enabled`
и до сборки клиента не доходит вовсе. Недоступность Telegram по-прежнему подъёму
не мешает — ровно как решено здесь. Разведение двух значений — отдельная запись,
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md).
@@ -0,0 +1,63 @@
# ADR-2026-08-14. Учётная запись с записями не удаляется, и это осознанный тупик
- **Дата:** 2026-08-14
- **Источник:** [openspec/changes/archive/2026-08-14-record-ownership/design.md](../../openspec/changes/archive/2026-08-14-record-ownership/design.md), раздел `Open Questions`
## Решение
Удаление учётной записи, у которой остались задачи расшифровки либо файлы,
отвергается — отказом с названной причиной. Способа удалить записи в сервисе нет
вовсе, поэтому до задачи про удаление записи такая учётная запись не удаляется
никак: ни владельцем панели, ни самим человеком.
Решение принято человеком на чекпоинте задачи `record-ownership` из трёх
предложенных способов.
Дословно из источника:
> **Что делать с записями удалённого пользователя?** Связь при выключенном
> каскаде снимает ссылку — записи остаются, но становятся ничьими и
> недостижимыми по API навсегда. Способы: запретить удаление учётной записи, пока
> у неё есть записи; держать рядом со связью неизменяемый снимок идентификатора;
> признать потерю ценой и записать её.
## Почему
Колонка владельца — связь с учётной записью, и каскадное удаление у неё
выключено: сервис объявлен архивом и молча удалить чужой архив не вправе. Одного
этого мало, и проверка по исходникам `pocketbase@v0.39.10` показала почему: при
выключенном каскаде хранилище **вынимает** идентификатор из поля связи и
сохраняет запись без проверок. Задачи остались бы на месте, но стали бы ничьими —
а ничья запись по правилу той же задачи не достаётся по API никому. Архив
человека исчезал бы молча, и восстановить владельца было бы нечем: прежнего
значения не остаётся нигде.
Прежнее обоснование выбора связи вместо строки — «связь удержит целостность» —
было неверным, и это выяснилось на ревью дизайна.
## Чем платим
Владелец панели упирается в отказ, а выхода из него сегодня нет: удаление записи
приносит отдельная задача. Тупик назван прямо, а не обнаружен потом.
Отказ обязан доезжать до спрашивающего: хранилище пропускает наружу только свою
ошибку роутера, а всякую другую подменяет сообщением про обязательную связь.
Подсказка эта ведущая — единственная обязательная связь у задачи это файл, — и
владелец панели, поверив ей, пошёл бы удалять записи руками, то есть делать ровно
то необратимое, ради предотвращения чего запрет и заведён. Это нашло ревью кода.
## Что рассматривалось и отвергнуто
- **Неизменяемый снимок идентификатора рядом со связью.** Пережил бы удаление, и
запись можно было бы вернуть человеку. Отвергнуто: владельцем становится любая
строка, и целостность, ради которой выбрана связь, теряется.
- **Признать потерю ценой и записать её.** Дешевле всего сегодня — удаления
пользователей в сервисе нет вовсе. Отвергнуто: архив, теряемый одной кнопкой в
панели, противоречит решению от 2026-08-11 о том, что сервис — архив.
## Связанное
Запрет ставит сама сборка хранилища, а не вызывающий: сборка, забывшая его
позвать, теряет защиту молча — и теряла, пока его добавляли отдельной строкой
запуска. Норма — `openspec/specs/storage`, «Учётная запись с записями не
удаляется».
@@ -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`.
+39
View File
@@ -0,0 +1,39 @@
# Приложение живёт своим пространством адресов, а не общим с хранилищем
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
раздел «Переезд в `/app/`, а слой сессии — на корень»
## Решение
Все адреса приложения переехали из `/api/` в собственный корень `/app/`, а слой
предъявления сессии повешен на **группу корня**, а не на перечень адресов.
## Почему
Пространство `/api/` принадлежит хранилищу: оно вешает туда собственные наборы
адресов, и поменять этот префикс нельзя — он литерал библиотеки, а не настройка.
Свободных имён сегодня хватает, но соседство остаётся: обновление библиотеки
вправе занять новое имя рядом с нашим, и разойдутся они молча — тем же адресом
начнёт отвечать не тот обработчик.
Прецедент в проекте уже принят тем же доводом: адреса входа вынесены на `/auth/*`
решением от 2026-08-12.
Слой на корень, а не на перечень: «перечень рос бы с каждым новым адресом
приложения, и забытый в нём адрес молча перестал бы принимать куку».
## Последствия
- `+` соседство с чужими адресами кончилось: имя, занятое библиотекой, наших
адресов больше не задевает;
- `+` новый адрес приложения получает слой предъявления по построению, а не по
памяти того, кто его добавил;
- `` правило неизвестного пути перечисляет теперь четыре корня сервиса вместо
одного: `/api/`, `/app/`, `/auth/` и `/_/`;
- `` ограничитель частоты хранилища, настроенный на его собственный корень,
наших адресов не покрывает — своё правило заводится нами, и его включение
вводит в действие заодно умолчательные правила хранилища;
- `` ломка полная: прежние адреса приёма и опроса отвечают `404`. Оплачено
стадией — на сервере данных нет, внешней программы на прежнем контракте не
существует.
+33
View File
@@ -0,0 +1,33 @@
# Страница архива задаётся ключом, а не номером
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
раздел «Страница задаётся ключом, а не номером»
## Решение
Постраничное чтение своих записей идёт непрозрачным ключом по паре «время
заведения и идентификатор». Номер страницы отвергнут.
## Почему
«Приём пишет в голову той же таблицы записей, которую читает список, и человек,
загрузивший запись и листающий свой архив, — штатный сценарий. Номер страницы
сдвинул бы окно на единицу: последний элемент первой страницы пришёл бы вторым
разом первым элементом второй, а один элемент между ними не пришёл бы никогда.
Отказ молчаливый — ни кода, ни строки в журнале, — и человек видел бы архив, в
котором записи нет.»
Ключ полный: у записей, принятых одним запросом, время совпадает, и порядок
между ними одним лишь временем не определён.
## Последствия
- `+` запись, заведённая между двумя страницами, не даёт ни повтора, ни
пропуска;
- `+` порядок между записями с равным временем устойчив;
- `` экран с нумерацией страниц так не сделать — листать можно только
«дальше». Архиву это не нужно;
- `` ключ приходит от клиента и потому разбирается: время приводится к виду
хранилища, иначе побайтовое сравнение молча обращает условие в постоянную
истину или ложь.
@@ -0,0 +1,62 @@
# Node зовётся контейнером, а не ставится на машину разработчика
- **Дата:** 2026-08-15
- **Источник:** [../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md](../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md),
раздел «Node не ставится на машину, а зовётся контейнером»
## Решение
Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**,
а не вызывает их из `PATH`:
> Требованием к машине разработчика становится docker, которым и так собирается
> образ, — второго устанавливаемого окружения сверх `ffmpeg` не появляется
> вовсе.
Образ сборочного окружения берётся из ступени `Dockerfile`, а не объявляется
вторым числом в `Taskfile.yml`.
## Почему
Довод в дизайне назван прямо:
> Так снимается расхождение, которое иначе завелось бы молча: версия Node на
> машине разработчика и версия в образе — два разных числа, и собранное ими
> приложение различается ровно тогда, когда различаются они.
Отвергнуты два очевидных подхода, и оба с названной ценой. **Поставить Node на
машину** — вводит второе устанавливаемое окружение и разъезжается с версией в
образе. **Дать выбор — контейнер или локальный Node** — это второй способ делать
одно и то же, и собранное ими различалось бы в зависимости от того, у кого что
стоит.
## Почему это ADR
Запись проходит триггер **намеренным отказом** от очевидного подхода: поставить
Node на машину — ровно то, что делают по умолчанию, и отказ от этого надо
объяснить один раз, а не на каждом вопросе «почему у нас нельзя просто
`npm run build`».
## Что это меняет в прежнем решении
[ADR-2026-08-11-spa-on-vue](ADR-2026-08-11-spa-on-vue.md) записал последствием,
что «машина разработчика получает второе требуемое окружение сверх `ffmpeg`», и
подразумевал под ним Node. Окружением оказался **docker**. Сам выбор фреймворка и
наличие шага сборки это не пересматривает, поэтому статуса «заменено на» у той
записи нет: заменена не она, а толкование одного её последствия.
## Последствия
- `+` версия сборочного окружения живёт **одним** местом — ступенью
`Dockerfile`, — и своего шага сверки ей не нужно.
- `+` собранное в наборе проверок и собранное в образе совпадает, потому что
совпадает окружение сборки, а не потому что «обычно совпадает».
- `` **набор проверок перестаёт работать без docker**, и отказ этот приходит
кодом окружения. Тем же кодом приходит отказ реестра пакетов: сетезависимых
шагов в наборе становится два вместо одного.
- `` контейнер ходит под тем же пользователем, что и вызвавший, а кэш
установщика уводится наружу — обе частности обязательны: без них собранное
ляжет от `root`, а зависимости будут тянуться заново каждый прогон.
- `` **вес и время самой ступени в образе неизвестны**: финальный образ от неё
не растёт (ступень в рабочий слой не копируется), а время сборки решением
владельца от 2026-08-15 не замеряется вовсе.
@@ -0,0 +1,59 @@
# Обязательность владельца держит схема, а не приём
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
разделы «Схема теряет только необязательность владельца» и «Владелец записи
перестаёт быть необязательным и в модели»
## Решение
Колонка владельца у аудиозаписи и у файла перестала принимать пустое значение —
шагом схемы `202608140003`. Ничья запись не заводится ничем: ни приёмом, ни
конвейером, ни рукой в панели. Поле владельца в модели стало обычной строкой
вместо ссылки, которой позволено отсутствовать.
Существующие строки шаг **не проверяет**, и это принято сознательно: искать ничьи
строки надо запросом до выкладки.
## Почему
Цитата источника:
> **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
> сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
> уходила в конвейер, стоила денег на распознавание и не доставалась потом
> никому. Решение владельца от 2026-08-14 — обязательность держит схема.
Прежнее решение было обратным и записано спекой `storage`: «Колонка MUST
допускать пустое значение… Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема». Цену за него платили записи входа Telegram — у
них владельца не было по построению. Вход убран
([ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md)),
исключение исчезло вместе с ним, и владелец сервиса подтвердил, что записей без
владельца в боевой базе нет.
Про непроверку существующих строк цитата источника:
> **Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
> оракулом: хранилище держит обязательность связи проверкой записи при
> сохранении, а не ограничением таблицы. Смена признака на базе с ничьей записью
> проходит зелёным и такую запись оставляет… Заставить шаг считать строки самому
> владелец решил не делать: безопасность держится ручной проверкой, и она названа
> первым шагом плана перехода.
Правило «пустой владелец не совпадает ни с одной записью» при этом осталось и
избыточным не стало: схема запрещает **заводить** ничью запись, а правило —
**спрашивать** ничьим именем.
## Последствия
- `+` значения «владельца нет» не существует ни на одном уровне: ни в схеме, ни в
модели, ни в отборе.
- `+` дыра «ничью запись заводят руками в панели» закрыта тем же механизмом, что
и приём, — одним, а не двумя.
- `` откат шага возвращает необязательность, но операционно недостижим: команд
библиотеки сервис не подключает, и это верно для всех шагов схемы проекта.
- `` ничья запись, если её проглядят перед выкладкой, становится незакрываемой:
захват выдаёт её воркеру, а всякое сохранение — включая то, которым ставится
признак остановки, — отказывает. Следа не остаётся ни в метрике, ни в журнале
событий, только строка в логе контейнера.
@@ -0,0 +1,37 @@
# Длительность и размер лежат колонками записи, и равенство со строкой файла не поддерживается
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
раздел «Три новых колонки записи и один шаг схемы»
## Решение
Длительность и размер принятого легли колонками аудиозаписи, хотя обе величины
уже есть у строки её файла. Равенство между ними не поддерживается никем —
намеренно. «Неизвестно» эти колонки не выражают: ноль означает ноль.
## Почему
Обе величины показываются в списке, а список по норме `storage` читается без
содержимого. Ревью дизайна возражало: величины станут копиями, которые некому
держать равными. Решением владельца колонки остались, а равенство объявлено
**ненужным**: «на записи лежит снимок принятого, взятый приёмом один раз; на
файле — величины той копии, которой файл является сейчас». Уточнение
длительности — перечитали метаданные, сменили источник, нарезали длинную запись
— меняет вторые и не трогает первые. Это разные вопросы: «что человек прислал» и
«что лежит сейчас».
Отличимость «неизвестно» от нуля снята после ревью кода и по замеру: числовая
колонка хранилища пустого значения не держит вовсе и кладёт пустое нулём.
Платить за отличимость четвёртой колонкой-признаком либо текстовым типом у чисел
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
метаданными отвергается отказом и не заводится.
## Последствия
- `+` страница списка не читает по строке файла на каждую запись;
- `+` смысл у двух пар чисел разный и записан нормой, а не подразумевается;
- `` в применённом шаге схемы навсегда остаются две колонки, повторяющие
величины строки файла; расхождение между ними — не поломка, и заметить его
нечем;
- `` запись, заведённая рукой в панели без величин, покажет человеку ноль.
@@ -0,0 +1,40 @@
# Метка убранного входа не выставляется вовсе, а не обнуляется
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
раздел «Метка убранного входа не выставляется вовсе»
## Решение
Признак поднятого входа остался, а метки убранного входа в метриках нет вовсе —
ни со значением единицы, ни со значением нуля. Ряд `transcriber_intake_up` с
меткой `telegram` не появляется после выкладки.
## Почему
Цитата источника:
> Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
> Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
> владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
> месте.
Отвергнут очевидный подход — оставить ряд со значением нуля. Он выглядит
бережнее (отбор не ломается), но говорит неправду: значение нуля у этого признака
означает именно неподнятый вход, а не отсутствующий.
С единственным оставшимся входом проверяемым осталось только **множество меток**:
значение нуля у него недостижимо, потому что страница метрик отдаётся тем же
сервером, что и приём, — чтобы прочитать признак, надо дотянуться до входа, о
котором он сообщает. Различать поднятый и неподнятый вход признак станет снова,
когда входов у сервиса станет больше одного.
## Последствия
- `+` наблюдатель не видит вечного нуля, который читался бы как незакрытая
авария.
- `` отбор вида `transcriber_intake_up == 0` по убранному входу перестаёт
срабатывать молча: исчезновение ряда ловится `absent()`, а не сравнением.
Владельцу, если такой отбор был заведён, править его руками.
- `` требование «различать поднятый и неподнятый» стало непроверяемым до
возвращения второго входа, и это сказано в самом требовании прямо.
@@ -0,0 +1,63 @@
# Вход Telegram убран целиком, а не выключен признаком
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
разделы «Context» и «Формы решения, между которыми выбирали»
## Решение
Вход Telegram убран из сервиса целиком: клиент, транспорт обновлений, отправитель
сообщений, сборка входа при старте, список допущенных людей, секция настроек и
зависимость. Убран **временно** — возврат заводится новым изменением вместе со
связью чата с учётной записью.
Хранилище при этом не тронуто: колонки `tg_chat_id`, `tg_reply_message_id` и
значение `telegram` перечня источников остаются в схеме вместе с записями,
которые их заполнили.
## Почему
Цитата источника:
> Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
> пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
> нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
> записи заводятся, правило «каждая запись принадлежит человеку» действует
> наполовину.
Отвергнуты две формы решения, обе с названной ценой:
> **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
> 2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
> ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
> строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
> двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
> следующая задача.
>
> **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
> адресатом — чатом владельца строкой настроек. Отвергнуто потому, что заводит
> понятие «канал уведомления владельца», которое тут же переделает задача
> `ntfy-delivery`.
Решениями, которые это изменение отменяет, были
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md)
и
[ADR-2026-08-13-telegram-outage-does-not-block-startup](ADR-2026-08-13-telegram-outage-does-not-block-startup.md):
оба нормировали подъём входа, которого больше нет. Доводы их при этом устояли и
понадобятся возврату — оба продолжают отвечать на вопрос «что делать с входом,
чей внешний собеседник недоступен».
## Последствия
- `+` модель одна: оговорка про запись без владельца ушла из спек приёма,
доступа, конвейера и хранилища.
- `+` зависимость `go-telegram-bot-api` ушла из манифеста вместе с двумя путями
утечки токена, которые проект закрывал двумя задачами.
- `` у сервиса не осталось входа, которым человек может воспользоваться:
приложения нет, личных ключей для программ нет, и до этих задач запись кладут
собранным руками запросом с сессией из браузера. Владелец окно принял.
- `` записи, застрявшие в конвейере на минуту выкладки, доходят до текста, и
ответа в чат по ним не уходит. Смягчения нет: чат и есть убираемый вход.
- `` бот у Telegram остаётся зарегистрированным и на вид живым, а ключ доступа —
в настройках выкладки под возврат входа (решение владельца от 2026-08-14).
Отправитель голосового не получит ни ответа, ни отказа.
@@ -0,0 +1,73 @@
# Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC
- **Дата:** 2026-08-22
- **Источник:** [../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md](../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md), разделы Р1 и Р3
## Решение
Сервис перестаёт вести вход сам. Кто пришёл, он узнаёт из заголовка
`Remote-User`, поставленного обратным прокси, который сходил к Authelia;
заголовку верят только с адреса из объявленного перечня, а адрес берётся у
самого соединения. Учётная запись заводится первым обращением с новым логином и
находится по нему же дальше.
Убраны целиком: корень `/auth` с тремя адресами, куки `transcriber_session` и
`transcriber_login`, сверка состояния и проверочный код PKCE, обмен кода
внутрипроцессным запросом к роутеру хранилища, слои предъявления куки и запрета
продления, приведение настроек провайдера к конфигу, секрет клиента и срок жизни
сессии.
## Почему
Цитата из источника, раздел Р3:
> Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
> заголовку, который прокси поставил, сходив к Authelia.
>
> Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
> значение, живущее семь суток, отозванный у провайдера человек работал до
> истечения этого значения, и другого канала отзыва не было.
Оттуда же, Р1 — почему доверие судится адресом соединения, а не пересылаемым
заголовком:
> **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
> запрос. Барьер, который подделывается той же строкой, что и обходится, не
> барьер вовсе.
Отвергнут промежуточный вариант — заголовок как вход, сессия хранилища как
продолжение (Р3):
> Дешевле в работе (слой срабатывал бы раз в неделю, а не на каждом запросе), но
> возвращает ровно то, что задача убирает: значение, переживающее отзыв. Семь
> суток вернулись бы вместе с ним.
Контур к решению был готов заранее: обратный прокси уже отдавал `Remote-*` трём
соседним сервисам того же контура, а правила для этого сервиса там не было
вовсе — он не выложен.
## Последствия
- `+` Отзыв доступа действует со следующего запроса, а не через семь суток:
Authelia судит каждое обращение.
- `+` Секрет клиента исчез из конфига и из базы. Изъятие из инварианта «Секрет не
покидает конфиг» снято: чтение файла базы больше не равносильно чтению
секрета.
- `+` Своего протокола входа у сервиса не осталось — вместе с ним исчезли пять
накопившихся задач о его механике.
- `+` Панель закрывается доменом, а не правилом на литерал пути; обход подменой
знака перестаёт существовать.
- `` **Весь барьер держится на настройке прокси.** Прокси, добавляющий заголовок
вместо замены, открывает сервис любому под любым именем. Половину беды сервис
закрывает сам — запрос с двумя значениями заголовка не узнаёт никого, — вторую
проверить отсюда нечем: правило живёт в `pet-project-server`.
- `` **Логин у провайдера переиспользуем**, и новый его владелец получает архив
прежнего. Неизменяемого признака заголовок не приносит; не допускать
переиспользования — работа провайдера. Обратная сторона: переименование
заводит новую запись, а прежняя остаётся с архивом, который нечем ни слить, ни
убрать.
- `` Поиск учётной записи идёт на каждом запросе к области приложения вместо
раза в неделю. Уникальный индекс делает это одним обращением к базе; замера не
требовалось — сервисом пользуются единицы человек.
- `` Половина работы лежит вне репозитория: до того как правило прокси и правило
Authelia на домен заведут, сервис не узнает никого.
@@ -0,0 +1,98 @@
# Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком
- **Дата:** 2026-08-22
- **Источник:** [../research/storage-without-pocketbase.md](../research/storage-without-pocketbase.md) —
записка разведки о выборе хранилища
## Решение
PocketBase уходит из проекта целиком: состояние записей и метаданные переезжают
в SQLite, с которым сервис работает напрямую через `modernc.org/sqlite`, файлы
записей — в свой каталог со своей раскладкой, маршруты и слои — на `net/http`,
шаги схемы — на свой раннер. Панель администратора теряется и **не заменяется
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
базе.
**Два решения абзаца выше сменились при разметке изменения**, и заменившее
названо здесь.
Шаги схемы двигает библиотека `github.com/pressly/goose/v3`, а не свой раннер.
Инструмент выбрал владелец 2026-08-22: библиотека уже была в этом проекте и ушла
вместе с PocketBase, а из трёх норм, которые накат обязан выполнять, две
выполняет сама.
Остановленную запись возвращает в работу подкоманда `cmd/devtools resume`, а не
запрос к базе руками. Возврат сбрасывает не одно поле записи и пишет событие
журнала с происхождением `entity.EventOriginHuman`; рука за клавиатурой не делает
ни того, ни другого. Последствие ниже — «возврат остановленной в работу […]
делает запрос к базе руками» — читается этой сменой.
Доводы обоих решений записаны в
[design.md](../../openspec/changes/archive/2026-08-23-storage-without-pocketbase/design.md),
разделы «Шаги схемы двигает `goose`, а не свой раннер» и «Панель не заменяется
ничем, а возврат в работу делает подкоманда оснастки».
Запись заменяет три:
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md) и
[ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md).
**Что из заменённых решений подтверждается, а не отменяется.** Очередь остаётся
своей таблицей, захват — одним запросом с `RETURNING`, готовую библиотеку
очереди по-прежнему не берём: замер, которым это решено, снят на
`modernc.org/sqlite` — том самом драйвере, который остаётся и после ухода.
Отменяется у той записи одно слово: таблица перестаёт быть коллекцией.
Приложение остаётся в своём корне `/app/`
([ADR-2026-08-15-app-namespace](ADR-2026-08-15-app-namespace.md)). Файл записи
остаётся закрытым — но проверкой владельца в своём обработчике, а не защищённым
полем коллекции и коротким токеном.
## Почему
Разведка мерила не «хранилище против хранилища», а то, что библиотека держит в
этом коде. Цитата из источника:
> Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
> шесть ролей сразу, и только две из них про хранение.
Довод, на котором стоял перевод, отпал сам. Источник, раздел о трёх доводах:
> Вход делает само приложение с 2026-08-22 […]: пришедшего называет заголовок
> прокси, а учётную запись заводит наш `EnsureUser`. Пользователи в коллекции
> есть **потому, что их пишет наш код**, а не провайдер библиотеки. Довод,
> которым отвергнут отвергнутый вариант, перестал быть верным.
Отвергнут вариант «уйти в два шага», оставив панель жить в промежутке, и
отвергнут решением владельца: панель на стройке заменяется запросом к базе, а
вторая порция работы стоит дороже, чем то, что она сберегает.
Обстоятельство, которое назначило момент:
> на сервере данных нет и сервис остановлен, поэтому смена стоит только кода.
> Дешевле она не станет никогда — каталог `internal/controller/http` прирастает
> кодом на чужих типах с каждой задачей.
## Последствия
- `+` периметр сервиса становится только нашим. Панель `/_/` исчезает вместе с
дефектом `/%5f/` из [../security.md](../security.md), а пространство
хранилища `/api/` — вместе с необходимостью держать его открытым ради файлов.
- `+` пропадает секрет, которого не было до перевода, — пароль суперпользователя
панели.
- `+` файлы ложатся своей раскладкой, и загрузка частями, узнавание по хеш-сумме,
удаление записи и вторая копия рядом становятся обычной работой с файлами.
- `+` из сборки уходят шесть модулей, достижимых только через библиотеку:
`imaging`, `mailyak`, `jwt`, `fexpr`, `cobra`, драйвер MySQL.
`modernc.org/sqlite` остаётся, и сборка по-прежнему обходится без CGO.
- `` владелец сервиса остаётся без панели. Правку записи, возврат остановленной
в работу и просмотр очереди до появления экранов делает запрос к базе руками.
Задачи `audiorecord-actions` и `play-recording-in-app` этим становятся не
улучшением, а заменой утраченного инструмента.
- `` шаги схемы, отдачу файла, ограничитель частоты и настройку базы пишем и
сопровождаем сами. Единственный писатель у `modernc.org/sqlite` — наша забота
с этого дня.
- `` раскладка каталога данных меняется необратимо. Цена сегодня нулевая:
стройка, на сервере пусто; после первой боевой записи она перестаёт быть
нулевой.
- `` 1497 строк контроллера и 3075 строк его проверок написаны на
`*core.RequestEvent` и переписываются целиком.
@@ -0,0 +1,76 @@
# Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина
- **Дата:** 2026-08-23
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
решение 4
## Решение
Отладочная подстановка заголовков входа не требует от настроек ничего сверх
самого предохранителя `[server] debug`. Решение владельца на чекпоинте записано
в источнике дословно:
> «предохранитель по адресам не делаем, полагаемся только на параметр debug».
Рассматривалось требование, чтобы при включённом предохранителе перечень
доверенных адресов состоял только из петлевых записей; оно снято вместе с
предикатом «петлевая запись», который заводился ровно ради него.
Согласованность с барьером узнавания при этом остаётся: подставленный заголовок
проходит тот же перечень доверенных адресов, что и пришедший, и судит адрес та
же функция. Предохранителем это не служит — «от конфига она не требует ничего и
круга тех, кто мог назваться кем угодно, не расширяет».
## Почему
Решение покупает работоспособность отладочного входа там, где адрес пира не
петлевой:
> отладочный вход работает **внутри контейнера** — адрес пира там принадлежит
> сети докера, и она же стоит в боевом перечне, — а локальный прогон не
> переставляет перечень доверенных адресов на петлевой: петлевые записи
> добавляются к тем, что в нём уже стоят.
Цена названа в источнике прямо, и владелец принял именно её:
> Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
> машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
> имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, —
> а доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
> пришедший обычным путём**.
Между боевой выкладкой и открытым входом остаётся три вещи, и других нет:
умолчание предохранителя «выключено»; отказ старта при заполненной имитации без
предохранителя; боевой конфиг, который рендерит шаблон Ansible, а не
копируют с машины разработчика.
Отвергнуты вместе с адресным предохранителем ещё два подхода. **Принудительно
слушать петлевой адрес при включённом предохранителе** — «меняет поведение молча
… и закрывает ровно то, что решение покупает: внутри контейнера сервис слушает не
петлю». **Новый ключ `[server] listen`** — «публичная поверхность настроек ради
предохранителя, которого решением владельца нет».
## Почему это ADR
Триггер — **намеренный отказ** от очевидного подхода. Требовать петлевой перечень
при включённом отладочном входе — первое, что предлагает всякий, кто читает
модель угроз; отказ от этого оставляет боевую поломку, которую машина не
исключает, и объяснить его надо один раз здесь, а не на каждом ревью, которое
эту дыру находит заново.
## Последствия
- `+` Отладочный вход работает и на машине разработчика, и внутри контейнера:
перечень доверенных адресов остаётся границей доверия, а не признаком отладки.
- `+` Локальный прогон не переставляет перечень на петлевой — петлевые записи к
нему добавляются.
- `+` Предиката «петлевая запись» в коде нет вовсе: он заводился ради одной этой
проверки.
- `` **Машина не исключает боевую поломку «конфиг с `debug = true` и
заполненной имитацией».** Такой сервис поднимется на любом перечне доверенных
адресов и назовёт своим именем всякого, кто пришёл обычным путём. Записано это в модели
угроз, [security.md](../security.md), «Периметр», и в спеке
[access](../../openspec/specs/access/spec.md).
- `` Одна из трёх опор лежит вне репозитория: шаблон Ansible из
`pet-project-server`. Проверить её отсюда нечем — тем же свойством обладает
правило прокси про заголовки `Remote-*`.
@@ -0,0 +1,86 @@
# Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс
- **Дата:** 2026-08-23
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
решения 1, 6, 7 и 10
## Решение
Заголовки входа на машине разработчика ставит сам сервис — отдельным слоем
цепочки корня приложения, а не вспомогательным процессом рядом:
> отдельный слой цепочки корня приложения, стоящий **перед**
> `TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
> запроса и ничего больше не делает: учётной записи не заводит, отказов не
> выдаёт, в контекст не пишет.
Включают слой два новых ключа настроек — предохранитель `[server] debug` и
секция значений `[auth.test_headers]`. Прежний вспомогательный процесс уходит:
> **Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
> остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
> входить локально не остаётся тоже.
Имена заголовков служат именами ключей секции, но набор принимаемых имён
порождают константы транспорта: дом у имён остаётся один, а ключ, не совпавший
ни с одним из них, роняет старт.
## Почему
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
Дизайн записал это целью:
> Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
> назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
> настроек, и ничем больше.
Довод в пользу слоя перед узнаванием, а не внутри него:
> Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
> неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
Отвергнуты три очевидных подхода, и у каждого названа цена. **Подстановка внутри
`TrustedHeaderIdentity`** — «узнавание получило бы второй источник значений и
ветку, которой в бою нет. Отлаживалась бы не боевая ветка, а её отладочный
двойник». **Произвольная карта имён заголовков в конфиге** — «опечатка
`Remote-Usr` даёт „сервис меня не узнаёт“ без единого следа». **Вырезать
подстановку из боевой сборки тегом сборки:**
> сборка образа в гейте не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег,
> забытый в одной ступени, дал бы ровно ту тишину, которой избегает пункт 3.
## Почему это ADR
Триггер сработал дважды. **Дорогой откат:** решение заводит два имени ключа
настроек, а имя ключа конфига `CLAUDE.md` называет необратимым; вернуться к
вспомогательному процессу значит поднять удалённую подкоманду, убрать оба ключа
из настроек и переписать рецепт локального запуска, разошедшийся по образцу
конфига, `README.md`, `CLAUDE.md` и конвенции настроек. **Намеренный отказ:**
вырезать отладочный код из боевой сборки тегом сборки — то, что делают по
умолчанию, и отказ от этого объясняется один раз здесь, а не на каждом вопросе
«почему подстановка вообще есть в боевом бинарнике».
## Последствия
- `+` Локальный запуск идёт одним процессом и одной командой; приложение
открывают по адресу сервиса, второго порта нет.
- `+` Отлаживается боевая ветка узнавания: подставленный заголовок неотличим от
пришедшего от прокси к моменту, когда его читают.
- `+` Второго способа входить локально не остаётся, и документация перестаёт
каждый раз говорить, какой из способов чей.
- `+` Имена заголовков остаются с одним домом — константами транспорта; ключ, не
совпавший ни с одним из них, роняет старт и называет принимаемые имена.
- `` **Местный инструмент больше не воспроизводит поломки контура.** Цена
названа в источнике прямо: два значения `Remote-User`, заголовок с
недоверенного адреса, цепочка `X-Forwarded-For` — всё это теперь
воспроизводит только автотест, ставящий заголовок сам.
- `` В боевом бинарнике появляется код, называющий пришедшего без провайдера.
Что его держит и чего у него нет — [ADR-2026-08-23-no-address-guard-for-debug-login](ADR-2026-08-23-no-address-guard-for-debug-login.md).
- `` У ключа `[server] debug` закрытый перечень следствий, и держать его
придётся руками: новое поведение привязывается к ключу только отдельным
решением владельца и получает своё требование спеки
[access](../../openspec/specs/access/spec.md). Ключ с открытым перечнем
следствий обрастает ими молча.
- `` Каждый новый логин имитации заводит учётную запись, а удалять их сервис не
умеет. Локальная база ронится и пересоздаётся свободно, в бою подстановка
выключена — но лишние записи копятся.
+72
View File
@@ -0,0 +1,72 @@
# Журнал решений
Одна запись — одно решение. **ADR продвигает уже написанное решение, а не
сочиняет его заново**: запись цитирует решение и ссылается на источник —
`openspec/changes/archive/<id>/design.md`, а у решения, принятого разведкой без
изменения, на её записку.
## Когда заводить
Верно одно из трёх:
- **дорогой откат** — переделка стоит дороже переписывания одного файла;
- **намеренный отказ** от очевидного подхода;
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус
«заменено на».
Не заводить для рутины и для того, что видно из кода и `git log`.
## Соглашения
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
- Записи неизменяемы **в решении**: передумали — новая запись, старой ставится
статус. Уточнить прежнюю запись можно только строкой «*Уточнено ГГГГ-ММ-ДД:*» в
разделе «Последствия» и только фактом, который решения не меняет, — например
действующим адресом того, что решение завело.
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
источником, а не абзацем в теле.
## Записи
Новые сверху.
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-23 | [Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина](ADR-2026-08-23-no-address-guard-for-debug-login.md) | |
| 2026-08-23 | [Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс](ADR-2026-08-23-test-headers-substituted-by-service.md) | |
| 2026-08-22 | [Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком](ADR-2026-08-22-storage-without-pocketbase.md) | |
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | |
| 2026-08-15 | [Длительность и размер — снимок принятого колонками записи](ADR-2026-08-15-record-snapshot-columns.md) | |
| 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | |
| 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | |
| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | |
| 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) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | устарело |
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | |
Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений
не имеют — сочинять их задним числом правило запрещает.
+23
View File
@@ -0,0 +1,23 @@
# Краткий заголовок решения
- **Дата:** ГГГГ-ММ-ДД
- **Источник:** openspec/changes/archive/<id>/design.md — либо записка разведки,
если решение принято без изменения
Статус ставится тем же полем и только при пересмотре:
`- **Статус:** заменено на ADR-…` либо `- **Статус:** устарело`.
У активной записи поля нет.
## Решение
Что именно решено — одной фразой.
## Почему
Намерение и причина. Цитата из источника, а не пересказ. Пиши так, чтобы через
год было понятно без чтения переписки.
## Последствия
- `+` что стало лучше.
- `` чем платим: ограничения, риски, нагрузка на сопровождение.
+425
View File
@@ -0,0 +1,425 @@
# Архитектура
Обзор: как сложено и где что работает. **Поведение системы здесь не описывается**
— нормативно оно живёт в `openspec/specs/`. Места, где оно всё-таки описано,
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
[passport.md](passport.md) и в [tasks/BACKLOG.md](../tasks/BACKLOG.md); что из
этого ещё не решено — в разделе «Открытые вопросы».
Заведённые capability нормируют **поведение сервиса** для его потребителей —
все до одной. Инструмент, которым сервис собирают, спеками не нормируется вовсе:
у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением
от 2026-08-13 его нормы живут в самих шагах, их проверках и
[conventions/go-linters.md](conventions/go-linters.md).
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
входов**: приём только от узнанного, имя отправителя не доходит ни до
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
наблюдатель видит единственный поднятый вход. Задачи
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
`pocketbase-storage` и `oidc-login` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
2026-08-14, `storage-without-pocketbase` 2026-08-22;
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
повтором и молчание конвейера наружу: задачи
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
2026-08-14 и `storage-without-pocketbase` 2026-08-22. Переходы состояний и отмена
контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задачи `pocketbase-storage`
2026-08-12 и `storage-without-pocketbase` 2026-08-22. Последняя убрала
встроенное хранилище целиком: база стала своей, файлы — своим каталогом,
панель владельца исчезла и не заменена ничем;
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
целиком и вложением, как из сохранённого строится структура реплик без
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
Задача `record-centric-model` 2026-08-14;
- [archive](../openspec/specs/archive/spec.md) — **архив своих записей глазами
приложения**: пространство адресов `/app/` и единая форма отказа с
машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само
чтение — страница записей ключом, карточка без текста и текст названного вида.
Здесь же обязанность, переехавшая с убранного опроса готовности: причину
остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa`
2026-08-15;
- [webapp](../openspec/specs/webapp/spec.md) — **приложение в браузере**: чем
сервис его отдаёт, каким адресом оно открывается, что делает обновление
страницы посреди него и что человек видит, открыв его. Здесь же правило
неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения
ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15;
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: узнавание по заголовку доверенного источника, заведение учётной
записи первым обращением и то, какие адреса остаются открытыми. Собственный
вход через OIDC жил здесь с 2026-08-12 по 2026-08-22 и убран задачей
`trusted-header-login` — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
записи не бывает вовсе — колонка владельца пустого значения не принимает.
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14. Здесь же
изъятие отладочного запуска: при включённом предохранителе `[server] debug`
заголовки входа подставляет сам сервис значениями из `[auth.test_headers]`с
отказами старта, строкой журнала и закрытым перечнем следствий ключа. Задача
`config-test-headers-login` 2026-08-23; решения —
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md)
и [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
Задача, которая его трогает, дописывает спеку своей capability.
## Принципы
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит таблицей базы; неделимость
захвата и порядок выборки нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
кандидатов в [research/job-queue.md](research/job-queue.md). Решение пережило
уход встроенного хранилища: замер снят на том же драйвере, и отменилось у него
одно слово — таблица перестала быть коллекцией.
- **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда
задача возвращается в работу, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
в работу»; здесь это принцип письма шага, а не описание поведения.
- **Подставной собеседник в боевом бинарнике объявлен своим ключом.** Дом ему —
код или оснастка; в боевом бинарнике он появляется только отдельным решением
владельца и только под ключом, названным своим предметом: имитацию заголовков
входа объявляет секция `[auth.test_headers]`, подмену распознавания — правка
кода (`internal/adapter/recognizer/memory.go`). Ключ, названный общим словом,
обрастает следствиями молча, и выключить его перестаёт означать «сервис ведёт
себя как в бою». Предохранитель `[server] debug` вторым именем собеседнику при
этом не служит и правилу не противоречит: собой он не называет ничего, а держит
**закрытый** перечень следствий, и перечень этот ведёт спека
[access](../openspec/specs/access/spec.md), «Предохранитель отладки включает
только подстановку заголовков». Новое следствие вешается на ключ только новым
требованием той же спеки.
- **Чистая архитектура.** Зависимости направлены внутрь, к домену: внутренний
слой не знает внешнего никогда. `internal/service` знает только
`internal/contract`; ffmpeg, Yandex и хранилище подставляются в точке входа
`cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель
домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и
они же держат обратные направления: транспорты не знают друг о друге, адаптер
не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие,
разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с
предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей,
и правило на это направление заведено впервые.
## Слои и модель домена
**Подход — чистая архитектура.** Зависимость идёт только внутрь: домен не знает
ни хранилища, ни транспорта, а знание о внешнем мире живёт интерфейсом в портах
и реализацией в инфраструктуре.
| Слой | Дом | Что живёт | Чего не знает |
| --- | --- | --- | --- |
| Домен | `internal/entity` | сущности, объекты-значения, доменные события, инварианты значениями | ничего, кроме стандартной библиотеки и единой точки времени `internal/clock` |
| Порты | `internal/contract` | интерфейсы репозиториев и внешних служб, типизированные ошибки | реализаций |
| Прикладной слой | `internal/service` | шаги конвейера: порядок, повтор, приговор | адаптеров и входов |
| Инфраструктура | `internal/adapter` | репозитории, ffmpeg, Yandex, шаги схемы | ядра и входов |
| Входы | `internal/controller` | HTTP и пул воркеров | друг друга |
| Сборка | `cmd/transcriber` | подстановка реализаций в порты, подъём сервера и пула | — |
Направления держат тесты-сканеры `internal/archrules` — все, кроме чистоты
самого домена. **Её не держит ничто**: правила смотрят ядро, входы и адаптеры, а
импорт внешней библиотеки в `internal/entity` сегодня пройдёт молча.
**Модель домена ведётся тактическими шаблонами DDD.** Шаблон называется здесь
вместе со своим сегодняшним предметом — перечень растёт вместе с моделью:
- **Сущность** — `entity.AudioRecord`: у неё идентичность и поведение
(`MoveToState`, `Halt`, `Resume`, `Postpone`), а не набор полей при сервисе;
- **корень агрегата** — она же: файлы, тексты, структура, попытки распознавания и
журнал событий принадлежат записи и живут её идентификатором, а правит агрегат
держатель захвата;
- **объект-значение** — `entity.Stage` со своими сроками, `entity.StuckLimits`,
`entity.Replica`, `entity.RecognitionResult`: сравниваются по значению и своей
идентичности не имеют;
- **доменное событие** — `entity.RecordEvent`: что случилось с записью, чьей
рукой и чем кончилось;
- **репозиторий** — интерфейсы `internal/contract`, реализации под
`internal/adapter/repo`;
- **служба домена** — правило, не принадлежащее одной сущности, живёт функцией
пакета домена (`entity.WorkingStages`, `entity.SanitizeOriginalFilename`);
- **фабрика** — `entity.NewInProgressResult` и соседи: значение приходит
согласованным, а не заполняется полями снаружи.
**Анемичной модели не заводим.** Новое поведение записи ищет дом сначала в
домене; прикладной слой назначает порядок шагов, а не правила. Признак нарушения
наблюдаем: правило о записи, записанное в `internal/service` условием над её
полями, принадлежит `internal/entity`.
Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища —
снято задачей `storage-without-pocketbase` 2026-08-22. Своя отдача файла и свои
маршруты вернули транспорту независимость от инфраструктуры, а узнавание
пришедшего приходит ему интерфейсом `contract.UserRepository`.
## Компоненты
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->
| Компонент | Где | Что делает |
| --- | --- | --- |
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Условия, при которых звено подстановки встаёт в цепочку, нормирует [access](../openspec/specs/access/spec.md), «Отладочный запуск называет пришедшего настройками» |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
| Репозитории | `internal/adapter/repo/sqlite` | Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с `RETURNING` по пишущему соединению |
| Файлы записей | `internal/adapter/repo/sqlite`, `store.go` | Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование |
| Шаги схемы | `internal/adapter/repo/sqlite/migrations` | Файл на шаг, версия — число в начале имени; накатывает `pressly/goose/v3` под своим замком |
| Оснастка владельца | `cmd/devtools` | Возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи |
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
Цепочка рубежей — `uploaded``normalized``submitted``transcribed`
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
[pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет
достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
настройкой.
## Внешние границы и форматы
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла записи, то есть её идентификатор с
расширением; идентификаторы строит `internal/ident` и они ULID, а не UUID.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
`deferred-general`, авторизация заголовком `Api-Key`. Распознавание
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не
нужен. База и файлы записей лежат под одним каталогом данных.
- **`github.com/BurntSushi/toml`.** Формат единственного источника настроек.
Негодный TOML останавливает старт; незнакомый ключ разбор не судит и молча
отбрасывает — наблюдение и чем оно проверено, в
[research/toml-unknown-keys.md](research/toml-unknown-keys.md).
- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной
строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте.
Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных
берём сами.
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
сетезависимый адрес набора проверок; все такие перечислены в
[CLAUDE.md](../CLAUDE.md), «Гейт».
## Эксплуатация
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
обратный прокси, который публикует HTTP-порт наружу.
- **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет
целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
сервис поднимает молча. Чем это обеспечено и как проверено —
[research/toml-unknown-keys.md](research/toml-unknown-keys.md); тем же
свойством безопасно и обратное направление: прежний образ поднимается на
конфиге с ключами, которых он ещё не знает.
- **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных
сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат
другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на
таком каталоге поднимется, накатит **свои** шаги в пустое место и заведёт
вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага
схемы нет и не планируется.
Прежние два порога — шаги `202608140002` и `202608220001` — этим поглощены: до
выкладки `record-centric-model` откат работал, после перестал, а с уходом
встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис
не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не
потому, что риск сегодня чем-то грозит.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»:
<!-- канон: поведение → openspec/specs/intake, pipeline, storage -->
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- |
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; карточка записи отдаёт рубеж `done` с пустым перечнем доступных видов текста |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
«падает приведение» отличается от «падает распознавание». Плюс логи
контейнера. Отдельного оповещения нет.
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на
него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением».
## Единые точки проекта
| Что | Где |
| --- | --- |
| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
| Возврат остановленной записи в работу | `cmd/devtools resume` — зовёт домен и пишет событие журнала записи с происхождением «человек»; колонок сама не пишет |
| Выдача идентификатора строки | `internal/ident` — ULID в нижнем регистре, монотонный внутри миллисекунды; разбор пришедшего снаружи — там же |
| Подключение к базе | `internal/adapter/repo/sqlite.Open` — пишущее соединение одно, чтение своим пулом, настройки строкой подключения обоих |
| Накат схемы | `internal/adapter/repo/sqlite.Migrate` — до подъёма входов и до старта воркеров, под замком каталога данных |
| Раскладка файлов записи | `internal/adapter/repo/sqlite.Store` — подкаталог на запись; путь на диске за её пределы не выходит |
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход записи на рубеж | `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_` |
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository``internal/controller/http.TrustedHeaderIdentity` |
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
| Имена заголовков входа | `internal/controller/http.IdentityHeaderNames` вместе с константами рядом — тройка `Remote-*` перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией `[auth.test_headers]`; что делает старт с ключом вне набора, нормирует [access](../openspec/specs/access/spec.md), «Настройка, открывающая вход всем, роняет старт» |
| Сверка адреса пира с перечнем доверенных | `internal/controller/http`, `identity.go``peerAddress` и `isTrusted`. Зовут их узнавание, подстановка заголовков отладочного запуска и ограничитель частоты. Свой сверщик разошёлся бы с общим молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится |
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и
запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей
`json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую
базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22
задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище
своим алфавитом, а сервис звал `uuid.NewString()` по месту.
## Деплой
Образ собирается по контракту роли `app_image`: `task image` даёт
`transcriber:$BUILD_ID`, по умолчанию `transcriber:dev`. Реестр не участвует —
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
`inv pl -- transcriber` из `pet-project-server`.
Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение
собирается первым — вшивание требует готового каталога, — а в рабочий слой Node
не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс
работает под непривилегированным пользователем `transcriber` — в образе он
назван числом, `USER 1000:1000`, а не именем: имя разрешает в идентификатор сам
образ, и хост, которому надо понять владельца файлов в смонтированном каталоге,
разрешить его не может. Числа те же, что при заведении пользователя.
Ступень бинарника собирает **одну** точку входа — `./cmd/transcriber`, а не весь
пакет: рядом в `cmd/` живёт `devtools`, оснастка разработчика, и в образе ей
делать нечего.
Оснастка лежит **одним** пакетом с подкомандами, а не пакетом на инструмент, и
это счёт, а не вкус: каждый отдельный пакет стоит четырёх мест — строка сборки
здесь, «Деплой» в этом файле, «Команды» в памятке, `README`, — и забытая строка
сборки тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
четыре места однажды, сколько бы подкоманд в нём ни завелось.
Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес:
у musl запрос имени идёт `A` и `AAAA` разом и ждёт **оба** ответа, поэтому
DNS-сервер, молчащий на `AAAA`, оставляет установщика пакетов без адреса при
живом `A`. Установщик уходит в повторы с нарастающей паузой на каждом пакете, и
сборка не краснеет, а **висит** — исход хуже красного. Слои этой ступени в
рабочий слой не едут, поэтому её вес остаётся ценой одной сборки.
**По весу финальный образ от ступени приложения не растёт вовсе:** она отдаёт
следующей только собранное, а сама в рабочий слой не копируется. Вшитое
приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не
замерялось и замеряться не будет — решение владельца от 2026-08-15.
## Открытые вопросы
- **Учётные записи.** Кто пришёл, сервис узнаёт заголовком, который ставит
обратный прокси, сходив к Authelia; учётная запись заводится первым обращением
и находится по логину у провайдера. Задача `trusted-header-login` 2026-08-22.
Собственного входа, куки и срока сессии у сервиса не осталось — отзыв доступа
судит провайдер на каждом запросе, а не однажды выданное значение. Норма —
[access](../openspec/specs/access/spec.md), решение —
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
зависит возвращение убранного входа.
Второго периметра на порту сервиса при этом не осталось: панель администратора
ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси
больше нечего.
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
Решено делать SPA,
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
сборкой Vite — 2026-08-11,
[ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
[research/spa-framework.md](research/spa-framework.md). Тем же решением Node
входит в гейт и слоем в сборку образа; как именно он зовётся — решением
[ADR](adr/ADR-2026-08-15-node-in-container-not-on-machine.md) 2026-08-15.
Не решено, брать ли готовый набор компонентов.
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом карточки.
Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
текст расшифровки начинает уходить на сторону — сдвиг периметра
[security.md](security.md).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения
`deferred-general` по длине не выяснены. Расчётные
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
записи живёт в хранилище»; откуда взято число —
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой
она осталась только этим числом.
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
задан числом в [database.md](database.md), «Настройки с числовым значением»;
обрыв начинает загрузку заново.
Загрузку частями разбирает разведка `chunked-upload-choice`; её выбор меняет
публичный контракт приёма и потому идёт через решение в `adr/`.
- **Учёт расхода.** Распознавание и языковая модель оплачиваются по факту, а
учёта по пользователям нет: метрики считают сервис целиком. Что именно
копится — записи о потреблении или счётчики — решает задача
`usage-accounting`.
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается.
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно
ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из
имени и о годности содержимого спрашивает источник метаданных, — но конвертер
на этом случае не проверялся.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
паузы, а не замер
([research/job-queue.md](research/job-queue.md), «Как снималось»), — при
нагрузке в единицы записей в день, и во что это обходится, никто не мерил.
Хранилище при этом сменилось задачей `storage-without-pocketbase` 2026-08-22
([ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md)), а модель очереди
пережила смену: отменилось одно слово — таблица перестала быть коллекцией.
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
выкладке сегодня нет.
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
решено, отдельный это шаг конвейера или продолжение шага распознавания.
+62
View File
@@ -0,0 +1,62 @@
# Конвенции кода
Как мы пишем код — в отличие от `openspec/specs/`, который описывает, что
система делает, и от [../architecture.md](../architecture.md), который описывает,
как она сложена.
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
перечень «Механизировано» записи [go-linters.md](go-linters.md) — дома правил,
которыми машина читает код. Причина: файл на несколько сотен строк
размазывает внимание по тривиальному — и модель, и человек добросовестно
проверят именование и не дойдут до формы решения.
Обоснование «почему именно так» живёт в [../adr/](../adr/README.md); инварианты с
severity — в [CLAUDE.md](../../CLAUDE.md).
## Откуда взяты и что с расхождениями
Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg`
предложение с заглавной буквы вместо константной категории.
Часть перечня закрыта. Доменные ошибки проверялись приведением типа до
2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по
месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило
держит линтер. Образец конфига звался `config.dist.toml` до 2026-08-14, задача
`config-example-toml`. Эти места больше не долг, а регрессия.
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
Каждое такое место названо в своей записи строкой «*Расхождение:*». Читается оно
как **долг, а не как нарушение**: правила действуют на новый код, переписывание
существующего — отдельная работа. Проходу ревью строка «Расхождение» говорит, что
находка на этом месте уже известна и новой не считается.
## Записи
- [logging.md](logging.md) — логирование: уровень по адресату, единая логирующая
точка на доменной границе, словарь полей, `ext.*`, что не логируем.
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is` и
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
типизированной.
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
`0600`, самодокументируемый `config.example.toml`, проверка на старте.
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
ULID, разбор на входной границе, естественные ключи у деталей.
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
над `fetch`, показ ошибок и состояний списка.
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: два круга
(pre-commit и гейт), перечень правил и подавлений, порядок заведения нового
правила. Про инструменты, а не про то, как писать тесты.
## Что из этого проверяет машина
Перечень правил, доведённых до проверки, и место настройки каждого — в записи
[go-linters.md](go-linters.md). Там же сказано, что из перечисленного в прочих
записях осталось прозой и потому проверяется человеком на каждом ревью заново, и
там же названы остатки правил — то, что правило не ловит. Числа механизированного
здесь нет намеренно: оно протухает при каждом новом правиле.
+201
View File
@@ -0,0 +1,201 @@
# Конфигурация
Конвенция: *как* устроена и грузится конфигурация transcriber (TOML).
Правила оформления кода (How), не спецификация поведения.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: комментариями снабжена половина полей; единого места проверки на старте
нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.
**Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml`
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
приезжают из TOML»: наш рабочий код окружение не читает вовсе — читателя `.env`
в `cmd/transcriber` сняли 2026-08-23 вместе с зависимостью. Окружение остаётся
у границы SDK: `aws-sdk-go-v2` в `internal/adapter/recognizer/yandex/s3.go`
зовёт `config.LoadDefaultConfig`, а тот читает `AWS_PROFILE`, `AWS_CA_BUNDLE`,
`AWS_ENDPOINT_URL` и `AWS_ENDPOINT_URL_S3` и смотрит `~/.aws/config`.
## Принципы
- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
используем**: окружение наследуется дочерними процессами и видно через
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
- Грузим **один раз при старте** в одну типизированную структуру `Config`
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
прикладном коде нет, только загрузчик `internal/config`.
- Конфиг **неизменяем** после старта; смена параметров — перезапуск процесса.
## Файл и поиск
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
процесса.
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
- Образец в репозитории — **`config.example.toml`** (см. ниже); реальный
`config.toml` не коммитится.
## config.example.toml — самодокументируемый образец
`config.example.toml` коммитим как единый справочник по конфигу: все секции и
все поля. **Каждое поле снабжаем комментарием**, из которого ясно:
- **зачем** поле — что оно меняет в поведении;
- **допустимые значения** — перечисление или границы;
- **единицы измерения**, если применимы — секунды, байты, доля `01`.
```toml
[server]
port = <N> # порт HTTP-сервера
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
```
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
одним домом — таблица «Настройки с числовым значением» в
[../database.md](../database.md); `config.example.toml` — источник истины по
составу полей.
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
*Расхождение:* перечень доверенных адресов в секции `[auth]` образца заполнен
примером — подсетью docker, — а не оставлен пустым: пустое значение не говорит,
какой формы значение здесь ждут, а сервис с пустым перечнем не поднимается вовсе.
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
входом.
Там же, комментарием под секцией, стоит рецепт локального входа **одним связным
блоком**, а не тремя комментариями по месту: правки связаны между собой, и
применённая порознь любая из них роняет старт либо оставляет сервис никого не
узнающим. Рабочей строкой в образце стоит боевое значение —
перечень с адресом прокси и `debug = false`, — а секция имитации закомментирована
целиком: образец описывает боевую выкладку, а локальный вход — способ до неё
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
какое из них чьё.
*Расхождение:* петлевые адреса в рецепте названы **парой**`127.0.0.1` и
`::1`, — а не одним значением, хотя правило секции требует от образца только
формы значения. Причина в цене: браузер разрешает `localhost` в IPv6 не реже,
чем в IPv4, и перечень без `::1` даёт неузнанный запрос там, где человек ждёт
входа.
## Поля по дискриминатору `type`
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
бекендов или внешних сервисов), обязательность и опциональность полей определяет
значение `type`, а не фиксированный список секции.
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
обязательных полей; поля других значений не требуются. Неизвестное значение —
ошибка на старте с перечислением поддерживаемых.
- **Образец — по `type`.** В `config.example.toml`:
- основное (умолчательное) значение **предзаполнено** рабочими значениями;
- альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
полей (зачем, границы, единицы — как у обычных полей);
- так из примера видны все варианты и поля каждого, не открывая код.
Дискриминатора в transcriber пока нет; правило записано на случай второго
распознавателя.
## Секреты
Секреты доставляет **выкладка**, рендеря их прямо в `config.toml` (transcriber:
Ansible из `pet-project-server`). Приложение просто читает TOML — отдельного слоя
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
репозиторий и не окружение.
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`. Секрет клиента OIDC отсюда ушёл
2026-08-22 вместе с собственным входом.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`).
- В `config.example.toml` секретные поля — пустые строки.
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
строки, и загрузчик их не отличает от настоящего значения.
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
отрендеренный файл) — см. «Проверка и остановка на старте».
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
- **Отказ загрузки настроек не несёт содержимого файла.** Текст такого отказа
собирает библиотека разбора, и собирает она его из разбираемого куска:
`toml.ParseError` кладёт в сообщение само значение («Invalid float value: %q»).
Оборванная кавычка в строке секретного ключа — типовая поломка криво
отрендеренного шаблона выкладки — уносит ключ в журнал контейнера целиком, а
инвариант «секрет не покидает конфиг» помечен необратимым. Поэтому отказ
разбора пересобирается своими словами: путь, строка, столбец и последний ключ,
без сообщения библиотеки. Прочие отказы декодера (несовпадение типов,
неподдерживаемый тип) собраны из имён ключей и типов, значений в них нет, и их
текст остаётся как есть — иначе за разборчивость отказа платили бы там, где
платить не за что.
**Файл, способный нести секрет, называется в `.gitignore` и в
`.dockerignore`.** Путей наружу у такого файла два, и закрывает
их разное: git держит `.gitignore`, а контекст сборки образа — `.dockerignore`,
потому что docker `.gitignore` не читает. Сегодня в обоих названы `config.toml`
и `.env`. Правило записано прозой и держится чтением: сверка двух списков стала
бы проверкой над проверкой, а такие проект не заводит
([../../CLAUDE.md](../../CLAUDE.md), «Запреты»). До 2026-08-23 парность не
называл ни один документ, и прогон, снимавший мёртвого читателя `.env`, снял
строку с одной стороны — вернуло её ревью.
## Проверка и остановка на старте
Конфиг проверяем **на старте, до приёма трафика**. Негодный конфиг — лог `ERROR`
и выход с ненулевым кодом, не стартуем наполовину.
Что проверяем:
- обязательные поля заданы;
- каталоги хранилища существуют и доступны на запись;
- границы числовых полей соблюдены;
- ключи внешних сервисов не пусты.
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
выходит с кодом 1. Единого места проверки нет.
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
**Проверка, охватывающая две секции разом, живёт методом на корневой `Config`.**
Такая сегодня одна — `ValidateTestHeaders`: она судит `[server] debug` против
`[auth.test_headers]`, и ни в `Validate()` секции сервера, ни в `Validate()`
секции входа не помещается — секция начала бы знать о чужой секции. Зовётся она
из `cmd/transcriber` рядом с остальными. Имена принимаемых заголовков приходят
ей **доводом**, а не читаются из пакета настроек: дом у них один — константы
транспорта, — а `internal/config` транспорта не знает и знать не должен, иначе
`cmd/devtools`, которому нужен один разбор конфига, линковал бы всю поверхность
HTTP.
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
которой не управляют. Причина в цене умолчания: поднявшись с
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
силе для прочих секций, где секреты есть.
## Структура в коде
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
- Одна корневая структура `Config` с под-структурами по секциям. Перечень
секций и полей здесь не повторяем: источник истины по составу —
`config.example.toml`, действующие числа — [../database.md](../database.md),
«Настройки с числовым значением». Каталог данных задаётся одним ключом
`[storage] data_dir`
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
требует правки обоих мест.
- **Обязательное поле — поле, у которого умолчания нет намеренно.** Умолчание у
такого поля было бы угаданным намерением, и одна из двух ошибок стала бы
тихой. Форма записи: умолчания нет ни в `defaultConfig()` (причина — строкой
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
судит **разбор**`MetaData.IsDefined` из `toml.DecodeFile`, — потому что
значение отличить «не задано» от «задано нулём» не позволяет. В
`config.example.toml` у поля стоит значение свежей установки. Первым таким
полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
правила сейчас нет.
+74
View File
@@ -0,0 +1,74 @@
# Конвенция: база данных и идентификаторы
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует этому
целиком: ключи — ULID в нижнем регистре, выдаёт их единая точка `internal/ident`
(с 2026-08-22, задача `storage-without-pocketbase`), время читает единая точка
`internal/clock` (с 2026-08-13), и правило времени держит линтер. Расхождений у
записи не осталось.
**Механизировано:** сверка изменённого шага схемы с
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
(`forbidigo` плюс `internal/clock`) и согласованность колонок очереди
(тест-сканер `internal/archrules`). Прочие пункты — прозой; адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи. Выдача монотонна внутри одной
миллисекунды: колонка времени несёт секунды, и порядок записей одной секунды
задаёт ключ. Порядок ленты берут парой «время заведения и ключ» — одного
времени мало.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все
записи сущности в логах.
- **Точка генерации и разбора одна** — `internal/ident`: `New` выдаёт, `Parse`
разбирает пришедшее снаружи. Самодельных генераторов по месту вызова не
заводим.
## Канонический вид — lowercase
- Генерим и храним id в **нижнем регистре**. Сравнение строк в SQLite
побайтовое, поэтому любой внешний id (URL, форма, поле запроса) обязательно
проходит разбор до запроса к БД — разбор проверяет формат и нормализует
регистр (base32 ULID нечувствителен к регистру при декодировании).
- Синтаксически неверный id считаем несуществующей сущностью (404), без похода
в БД.
## Естественные и составные ключи — для деталей
- У таблиц-деталей и связей допустим естественный или составной ключ вместо
ULID, когда он есть по природе данных. Отдельный ULID там — мёртвый вес.
- Прочие генерируемые идентификаторы — тем же способом, что и ключи сущностей:
единый формат, сортируемость, корреляция в логах.
## Прочее
- Enum-поля (`state`, `halt_reason`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код. Прежде часть перечней закрывала схема — правку руками вела
панель владельца, и она вправе была завести значение, которого сервис не
знает. Панели нет с 2026-08-22, правка идёт только нашим кодом, и закрытый
перечень в схеме остался бы ценой — новое значение стоило бы нового шага — без
покупателя.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени.
Умолчаний вида `CURRENT_TIMESTAMP` в схеме нет ни у одной колонки, и вид один
на все — включая те, что пишет только сам сервис: своего типа времени у SQLite
нет, а колонка, заполненная то одним видом, то другим, молча обращает условие
срока захвата в константу.
- Миграции — шаги `pressly/goose/v3` на Go
(`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия — число в
начале имени): таблицы, их колонки и индексы заводятся кодом. При изменении
структуры обновляем схему [../database.md](../database.md) тем же изменением.
- Время в запросе кладётся и сравнивается тем же видом, каким оно лежит в
колонке. Сравнение строк побайтово, и разошедшийся вид обращает условие в
постоянную истину или ложь — молча.
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
сравнение по неуникальному значению делает порядок обработки
невоспроизводимым.
+182
View File
@@ -0,0 +1,182 @@
# Ошибки
Конвенция: *как* устроены и передаются ошибки в transcriber. Правила оформления
кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md),
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как ошибки
строятся, оборачиваются и проверяются.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
решают сами.
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint`,
сторонние пакеты ошибок — `depguard`, узнавание ошибки по тексту сообщения —
тест-сканер `internal/archrules`. Перечень и адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Базовая идиома: stdlib
- Только стандартный `errors` плюс `fmt.Errorf`: контекст ошибки несёт `slog`, а
не стек — стек-трейсы и внешний сборщик избыточны для домашнего сервиса.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
пересмотреть, а не умолчание.
## Обёртка и контекст
transcriber — **приложение, а не библиотека**: внешнего Go-API нет, весь код наш.
Поэтому внутри приложения обёртка `%w`**умолчание**, чтобы `errors.Is` и
`errors.As` работали сквозь слои.
- Добавляем контекст обёрткой: `fmt.Errorf("convert audio: %w", err)`.
- `%w` — когда вызывающий может смотреть причину (наш обычный случай). `%v`
когда причину сознательно **не** раскрываем.
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в цепочке, а
трансляцией на внешней границе (см. ниже).
Стиль сообщения:
- со строчной, без точки в конце, без «failed to» и «error» — обёртка и так
читается как «контекст: причина»;
- контекст — операция или субъект: `"acquire job: %w"`, а не
`"something failed"`;
- без заикания: каждый слой добавляет **свой** смысл, не повторяет нижний.
*Расхождение:* в коде преобладает форма `"failed to <действие>: %w"`.
## Проверка ошибок
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
`sql.ErrNoRows` превращается в доменную ошибку в слое репозитория, чтобы выше
по коду не торчал `database/sql`.
- Проверяем `errors.Is` и `errors.As`, а не сравнением и не приведением типа.
- **Признак домена читается только из ответа того шага, который его породил.**
`errors.As` распознаёт признак на любой глубине цепочки, а не только сверху,
— поэтому слой, придающий отказу собственный смысл, чужой признак в свою
цепочку не сохраняет. Иначе воркер примет отказ, к которому признак
примешался, за этот признак: зачтёт настоящий сбой пустым прогоном, и задача
продолжит переопрашиваться без единой записи в журнале. Норма записана требованием
[pipeline](../../openspec/specs/pipeline/spec.md).
## Sentinel и типизированные
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий, на
которые ветвится код. Проверяем `errors.Is`.
- **Типизированная ошибка** (тип с полями плюс метод `Error()`) — когда
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
где хватает sentinel.
Типизированные ошибки проекта несут данные все до одной:
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
(состояние), `contract.LostAcquisitionError` (идентификатор задачи).
Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был
снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а.
Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь
как случай, а не как живой код.
## Граница и трансляция: приватный и публичный канал
Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**, и
форма зависит от канала и от того, кто его видит:
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
`%w` и контекстом. Пишется один раз на доменной границе — см.
[logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда
отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
найти полную ошибку в логах. «При обработке задачи произошла ошибка, job_id
= …», а не «произошла ошибка» и не сырой текст.
**Ключ есть не у всякого транспорта, и это называется вслух.** Отказ приёма
случается до заведения задачи, и ключа у него нет вовсе — тогда сообщение
остаётся без якоря, а диагностика ищется по записи доменной границы.
Заводить транспорту собственный идентификатор запроса ради ключа — решение
уровня спеки, а не умолчание;
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
HTTP и веба:
| Доменная ошибка | Статус | `error_code` | Сообщение |
| --- | --- | --- | --- |
| пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
| файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
| текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
| прочее | 500 | `internal` | «внутренняя ошибка» |
Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
случаем: им был владелец панели, предъявивший собственный токен хранилища.
Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
учётную запись заводит само.
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
**Тело отказа несёт два поля — `error_code` и `message`.** Кода HTTP не
хватает: «файл негоден», «поля записи нет» и «неизвестный вид» — все три
`400`, а приложению надо решать, предлагать ли повтор. Разбор русской фразы
был бы единственным оставшимся путём. Норму держит спека `archive`.
Точка живёт в `internal/controller/http.mapDomainError` и названа в
[architecture.md](../architecture.md), «Единые точки проекта». Прежнее
расхождение — «такой точки нет, обработчик решает сам» — закрыто задачей
`json-api-for-spa` 2026-08-15.
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
неоткуда.
### Разовый ответ и сохранённая диагностика
У публичной границы две поверхности, и правило сырого текста для них разное.
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
и полезен. Но:
- **секреты запрещены** — токены, ключи, пароли, заголовок
авторизации. Ошибка транспорта может нести URL с токеном внутри, и её
вычищают на границе клиента;
- это **не** канал для разовых отказов — те остаются нейтральными;
- **внешнее значение в тексте усекается на границе, а его размер называется
числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
машинного текста — эту часть правила держит спека `archive`.
## panic
- `panic` — только для невосстановимого: нарушенный инвариант, ошибка
инициализации, из которой нельзя стартовать.
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
роняет процесс целиком.
## Несколько ошибок
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
*Расхождение:* проверку конфига пункт называет поимённо, а ни одна из них так не
устроена: `errors.Join` в `internal/config` не зовётся нигде, и всякая проверка
возвращается на первом несовпадении. Заметило ревью задачи
`config-test-headers-login` 2026-08-23 — тем же прогоном, каким добавили
`ValidateTestHeaders`, ведущую себя так же. Человек, заполняющий конфиг
впервые, чинит одну ошибку за прогон.
+242
View File
@@ -0,0 +1,242 @@
# Линтеры и механизированные проверки
Конвенция о том, **чем машина читает наш код**: какие свойства доведены до
правила, чем каждое проверяется, когда оно запускается и что осталось человеку.
Свойство, ставшее правилом, из прозы соседних записей удаляется и появляется
здесь строкой — эта запись его принимает.
**Чего здесь нет: как писать тесты.** Запись говорит об инструментах и правилах —
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
обязан проверять. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
исходники.
Пока язык у проекта один, и запись названа по нему. Появится второй — у него
будет своя запись, а два круга останутся общими.
Устройство ниже **переносимо**: разделы «Два круга» и «Как заводят новое
правило» — не особенность transcriber и переносятся в другой Go-проект как есть.
Своё здесь — перечень правил и подавлений.
## Границы: где что живёт
Чтобы факт не жил в двух местах:
- **семантика гейта** — команда целиком, база диффа, словарь кодов выхода, что
красит безусловно, чего в гейте намеренно нет и кто тогда обязан это гонять —
в [CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь это не повторяется: у гейта
один дом, и он у памятки, потому что её читают прежде работы;
- **как писать код** — соседние записи этой конвенции ([README.md](README.md) —
индекс). Свойство, ставшее правилом, оттуда удаляется и попадает в перечень
ниже; обратный перенос запрещён — правило, оставшееся ещё и прозой, проверяют
дважды;
- **настройка конвейера ревью, вопросы по темам и журнал дефектов** —
[../review.md](../review.md). Перечень ниже говорит этим вопросам, чего
спрашивать уже не нужно;
- **поведение сервиса** — нормативные спеки `openspec/specs/`. Шаги набора
проверок туда не входят: инструментарий спеками не нормируется, и спека
`toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Своего
дома у нормы этого шага теперь нет вовсе — она живёт комментариями в
`scripts/check-go-version.sh`, и проверок у шага нет: двадцать сценариев снесены
тем же решением. Второй самодельный
шаг — `migrations` — не проверен и не был: он прогнан мутацией на трёх исходах
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это
не числится: проверок над проверками проект не заводит —
[CLAUDE.md](../../CLAUDE.md), «Запреты».
## Два круга: pre-commit и гейт
Проверки идут двумя кругами, и круг выбирается по цене прогона.
| | pre-commit (`lefthook.yml`) | гейт (`task gate`) |
| --- | --- | --- |
| Когда | на каждый коммит | перед тем как считать задачу сделанной |
| На чём | на **затронутых файлах** | на всём дереве |
| Сколько идёт | около секунды | десятки секунд |
| Что делает с находкой | `gofmt` правит и добавляет в коммит, прочее роняет коммит | роняет прогон |
Перечень работ pre-commit и то, что остаётся только гейту, — в
[CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь важен принцип: **pre-commit не
подменяет гейт**. Он ловит дешёвое и местное, а сборка, тесты целиком, сверки
документов и запрос к базе уязвимостей идут в гейте — иначе коммит стоил бы
минуту, и хук отключили бы через день.
Полный набор проверок в pre-commit не переносится сознательно; обратное решение
— «гонять всё на каждый коммит» — известно и отклонено по этой же причине.
## Механизировано
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
промптах ревью не пересказывается.
### Ошибки и отказы
| Правило | Где механизировано |
| --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove`, отложенные `(*sql.Rows).Close` и `(*sql.Tx).Rollback` и запись тела ответа (`json.Encoder.Encode`, `http.ResponseWriter.Write`) |
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
| Отказ выборки из базы не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. Предмет у правил появился 2026-08-22: выборки идут своим `database/sql`, и обе ветви ловятся на живом коде |
| Обращение к базе идёт с контекстом (`ExecContext`, `QueryContext`, `BeginTx`) | `.golangci.yml``noctx`. Контекст у репозиториев свой — почему, названо в [../database.md](../database.md), «Представление данных» |
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml``depguard` |
### Структура и границы
| Правило | Где механизировано |
| --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Транспорты не знают адаптеров | `internal/archrules``TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
### Отмена и внешний собеседник
| Правило | Где механизировано |
| --- | --- |
| Запрос и внешний процесс заводятся с контекстом (`exec.CommandContext`, `http.NewRequestWithContext`, `QueryContext`) | `.golangci.yml``noctx`. Единая точка не нужна: контекст приезжает доводом, а контракты `internal/contract` несут его первым |
| Контекст приезжает сверху, а не заводится по месту (`context.Background()` в середине цепочки) | `.golangci.yml``contextcheck` |
| Тело ответа HTTP закрывается | `.golangci.yml``bodyclose`. Отдельно от `errcheck`: там `(io.ReadCloser).Close` объявлен исключением, и незакрытое тело от невыясненного `Close` неотличимо |
### Время, вывод, конфигурация
| Правило | Где механизировано |
| --- | --- |
| Время читают `clock.Now` (метка, UTC) и `clock.Start` (длительность, монотонные часы) — не `time.Now` по месту | `.golangci.yml``forbidigo`; единая точка — `internal/clock` |
| Вывод идёт через `slog`, а не `fmt.Print*` и не встроенными `print`/`println` | `.golangci.yml``forbidigo`. Не ловит `fmt.Fprintln(os.Stdout, …)` — первый аргумент по имени функции не судится; остаток прозой в [logging.md](logging.md) |
| Конфигурация приезжает из TOML, а не из окружения | `.golangci.yml``forbidigo`: `os.Getenv`, `os.LookupEnv`, `os.Environ`, `os.ExpandEnv` — все четыре, иначе запрет обходится соседним именем |
| Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml``sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) |
### Код проверок и подавления
| Правило | Где механизировано |
| --- | --- |
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml``nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
### Форма кода и файлов вне Go
| Правило | Где механизировано |
| --- | --- |
| Форматирование исходников | `.golangci.yml``gofmt`; на pre-commit правится на месте |
| Подозрительные конструкции языка | `.golangci.yml``govet`, `staticcheck`, `ineffassign`, `unused` |
| Опечатка в комментарии и в тексте ошибки | `.golangci.yml``misspell` |
| Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit |
| Форма `Dockerfile` | `Taskfile.yml` → шаг `dockerfile` (`hadolint`), он же на pre-commit |
| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) |
| Форматирование и статический анализ кода приложения | `web/biome.json` → Biome, зовётся шагом `front` командой `npm run check`. Разбирает и однофайловые компоненты; правила — набор `recommended` плюс своя форма (одинарные кавычки, точка с запятой по необходимости) |
| Типы разметки и кода приложения | `vue-tsc`, и он входит в **команду сборки**, а не стоит отдельным шагом: несобираемое приложение и непроверенные типы — один отказ |
| Поведение экранов приложения | `Taskfile.yml` → шаг `front`, юнит-тесты Vue (`npm run test`). Без них требование «приложение показывает вошедшего» не проверял бы никто, а набор проверок оставался бы зелёным на сломанном экране |
### Хранилище, документы, секреты, зависимости
| Правило | Где механизировано |
| --- | --- |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни база: применённое считается своей таблицей учёта. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
| Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) |
Не названное здесь место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
## Подавления: что и почему
Подавление — это решение, а не настройка, поэтому каждое названо поимённо и с
причиной. Причина живёт строкой рядом с подавлением (в `.golangci.yml` или
`Taskfile.yml`), а здесь — их перечень, чтобы видеть все разом.
| Подавлено | Где | Почему |
| --- | --- | --- |
| `errcheck` на `defer Close` и `os.Remove` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
| `noctx` на `httptest.NewRequest` в `*_test.go` | `.golangci.yml`, `exclusions` | Фикстура запроса к обработчику в том же процессе: внешнего собеседника за ней нет, отменять нечего. Изъятие названо по имени этой функции, а не выключением `noctx` на проверках: настоящий внешний вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему подсуден, и это проверено мутацией |
| `SC1007` в `scripts/check-go-version.sh` | директива в скрипте | Ложное срабатывание на идиому `CDPATH= cd`, которая защищает `cd` от чужого `CDPATH` |
| `DL3007` (`alpine:latest`) | `Taskfile.yml`, шаг `dockerfile` | Открытая задача `pin-runtime-image-base`; до её решения шаг краснел бы на известном |
| `DL3018` (закрепить версии `apk`) | там же | Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели |
## Что остаётся прозой
**Из перечисленного в записях конвенций правилом выражено не всё.** Прозой
остаётся то, чему нет ни готового правила, ни детерминированного оракула:
уровень лога по адресату, единая логирующая точка на доменной границе, словарь
имён полей, канонический вид идентификатора, естественные ключи у деталей.
Свойство, оставшееся прозой, проверяет человек на каждом ревью заново, и правило,
снявшее с него эту работу, всегда выигрыш.
Названы поимённо и **остатки правил** — то, что правило не ловит и потому
осталось человеку:
- вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`:
`forbidigo` судит по имени вызванной функции, а не по её первому аргументу;
- проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`;
- **отсутствие** контекста у сигнатуры: `contextcheck` ловит обрыв цепочки —
`context.Background()` там, где контекст был доводом, — но метод, у которого
довода нет вовсе, правилу не виден. Первый проброс контекста в новый адаптер
остаётся человеку;
- шаг `migrations` судит только те шаги схемы, которые **есть в базе диффа**: у
добавленного после неё файла статус `A`, и правка такого файла законна — он
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
молчит на всём каталоге, и на подозрении база задаётся руками
(`task migrations BASE=<rev>`);
- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
[../architecture.md](../architecture.md), «Слои и модель домена».
Отдельно названы **правила, чей подъём отклонён**:
- `key-naming-case` у `sloglint` — словарь полей намеренно смешанный: доменные
поля `snake_case`, системные домены с точкой (`http.method`, `ext.service`);
- `msg-style: lowercased` у `sloglint` — это ровно конвенция «`msg` — короткая
константа в нижнем регистре», но код называет сообщения предложениями с
заглавной, и это объявленное *Расхождение*. Цена подъёма — переписать больше
ста вызовов, и она не заплачена;
- закрепление версий пакетов `apk` (`DL3018`) — см. подавления выше.
**Кандидат, ждущий решения:** `clock.Now` и `clock.Start` отдают один тип, поэтому
`time.Since(clock.Now())` компилируется и молча меряет длительность настенными
часами — ровно то, против чего пакет и написан. Держал бы это компилятор, будь у
`Start` свой тип с методом `Elapsed()`. Сегодня таких мест нет.
## Как заводят новое правило
Порядок один и тот же, и последние два шага пропускать нельзя.
1. **Найти дом.** Дом выбирается по тому, чем свойство выражается, а не по тому,
что проще включить: настройка готового линтера, запрет по имени, тест-сканер
исходников или свой шаг набора проверок.
2. **Написать причину рядом.** Правило без причины снимают при первом же
неудобстве: тот, кто снимает, не знает, что оно ловило.
3. **Починить находки, а не подавить.** Подавление годится, когда правило
говорит не о том, что мы имели в виду; тогда оно попадает в перечень выше с
причиной. Подавление «пока некогда» — это отложенная работа, и её место в
каталоге задач, а не в конфиге.
4. **Проверить мутацией.** Внести ровно то нарушение, против которого правило
написано, и убедиться, что проверка краснеет и называет место. Правило,
принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны
они в [../review.md](../review.md) (журнал 2026-08-11 про недостижимую норму,
2026-08-13 про обходимый текстовый запрет).
**Мутация обязана собираться.** Правка, снявшая последнее употребление
импорта, роняет сборку, а не проверку: вывод при этом похож на отказ, и
мутацию легко засчитать сработавшей. Прежде чем верить красному, убедись, что
красное — от проверки.
**Мутация ставится по одному нарушению на строку.** `golangci-lint` печатает
с одной строки исходника **одну** находку (умолчание `uniq-by-line`), и
мутация, задевшая сразу два правила, покажет только первое: так молчали
`sqlclosecheck` и `rowserrcheck` на пробе, где та же строка уже краснела от
`noctx`. Проверять правило пробой, где оно единственное нарушенное.
5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её
заменило.
+281
View File
@@ -0,0 +1,281 @@
# Логирование
Конвенция: *как* и *когда* писать логи в transcriber. Это правила оформления
кода (How), а не спецификация поведения — наблюдаемые требования к логам (что
система обязана залогировать как часть контракта capability) живут в спеках
OpenSpec.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: обработчик текстовый, а не JSON; уровень зашит `INFO` и не
настраивается; `msg` — предложение с заглавной буквы, а не константная
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
ошибку выше, где её логируют снова.
**Механизировано:** форма вызова — `sloglint`: только пары
«ключ-значение», `msg` константой, **атрибуты (`slog.String` и прочие) не
употребляются вовсе**. Запрет `fmt.Print*` и встроенных `print`/`println`
`forbidigo`; вывод в stdout через `fmt.Fprintln(os.Stdout, …)` правилом не
ловится и остаётся прозой этой записи. Прозой остаются также уровень по адресату,
единая логирующая точка и словарь имён полей: оракула у них нет. Адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Принципы
- Структурированный JSON (`slog.JSONHandler`), один формат для разработки и для
продакшена.
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
отдельный ключ с типизированным значением: это даёт отбор и сведение через
`jq` без регулярных выражений.
```json
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
```
*Расхождение:* текстовый обработчик ставит `cmd/transcriber`
`slog.NewTextHandler(os.Stdout, …)`.
*Изъятие:* оснастка разработчика `cmd/devtools` печатает не через `slog`, а
stdlib-логом в поток ошибок. Это выбор, а не долг: её вывод читает человек в
терминале, в сбор он не едет, а текст подсказки по командам `slog`-ом
выглядел бы хуже, чем есть.
## Сообщение
- `msg` — короткая константа в нижнем регистре: `record accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах:
`log.Info("record accepted", "record_id", id, "source", "api")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
жизненный цикл собирался одним отбором:
`jq 'select(.msg=="state transition" and .record_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`file converted`,
`text delivered`), она запись перехода не подменяет.
*Расхождение:* сегодня `msg` — предложение вида `Starting conversion job`,
поля `capability` нет, отдельной категории перехода нет.
## Уровни
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько громко
сломалось». `slog` даёт четыре уровня; их и используем.
| Уровень | Кому и когда | Примеры в transcriber |
| --- | --- | --- |
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |
Правила:
- Уровень **не зависит от capability**: `ERROR` в приёме и в распознавании
одинаково серьёзны.
- `WARN` не значит «ничего страшного». `WARN` значит «может стать проблемой».
Если это не «может» — это `INFO`.
- Меняется адресат — меняется уровень. Негодный ввод от пользователя — это
`DEBUG` (норма, владельцу разбирать нечего), а не `ERROR`.
- **Событийное — `INFO`, рутинно-частое — `DEBUG`.** Операция по реальному
действию (приём записи, запуск распознавания, отправка текста) идёт на `INFO`.
Повторяющаяся служебная операция, которую запускает таймер или опрос и которая
сама по себе события не несёт (проверка здоровья, пустой прогон воркера,
опрос готовности операции), — на `DEBUG`: на `INFO` она зашумляет разбор.
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
завершаем процесс с ненулевым кодом.
*Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
*Изъятие:* строка о подставленных заголовках входа адресована разработчику, а
идёт на `INFO` — уровень и его довод нормирует спека
[access](../../openspec/specs/access/spec.md), «Отладочный запуск виден в
журнале».
## Время
- Поле — `time` (ключ `slog` по умолчанию).
- UTC, RFC 3339 с долями секунды, суффикс `Z`.
- Логи — **в UTC**, как и хранение в БД: это даёт однозначный порядок событий и
лексикографическую сортировку. Часовой пояс есть только у **отображения**.
## Поля: словарь имён
Главное условие — **единый словарь**: одно поле, одно имя по всему коду.
- Доменные поля — плоский `snake_case`.
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
`ext.*`, `webapp.*`.
- JSON плоский: все поля на верхнем уровне, без вложенности.
| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms`, `http.path_length`. **Запрошенного пути в строке нет ни под каким корнем**: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В `http.route` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути — строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` |
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
*Расхождение:* в коде встречаются `record_id`, `file_id`, `operation_id`,
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
пересечён с этим лишь частично.
## Корреляция по id сущности
Отдельный случайный `trace_id` не заводим — у сущностей уже есть стабильные
осмысленные ключи: идентификаторы задачи и файла, они лежат в базе.
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
Для задачи — логгер с уже подставленным ключом, протаскиваемый сквозь стадии,
чтобы ключ дописывался на каждую запись сам:
```go
log := log.With("record_id", record.Id, "capability", "conversion")
```
- Все записи одной задачи собираются одним отбором:
`jq 'select(.record_id=="…")' app.jsonl`.
## Ошибки
Ошибки Go логируем как атрибут, а не как текст сообщения:
`log.Error("conversion failed", "error", err, "record_id", id)`. Ключ — `error`.
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
накапливается в цепочке `%w`.
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
исход операции. Логирует эта единая точка, а не каждый транспорт — так
транспорты остаются тонкими. Границы в transcriber:
- приём записи (`CreateJobFromApi`);
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
- завершение и отказ задачи (`completeJob`, `failJob`).
- Транспорты переводят возвращённую ошибку в свой ответ и **не логируют** её
повторно — иначе один сбой даёт дубли.
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой доменной
ошибки ровно один логирующий; уровень выбирает он.
| Класс отказа | Кому | Уровень |
| --- | --- | --- |
| негодный ввод, задача не найдена, действие сейчас недопустимо | пользователю (он уже получил ответ на поверхности) | `DEBUG` |
| запись распознана пустой, задача досталась повторно | владельцу, «может стать проблемой» | `WARN` |
| сбой БД, диска, недоступность внешнего сервиса | владельцу, в разбор | `ERROR` |
- **Повторяющийся сбой фонового цикла — `WARN`, а не `ERROR`.** Одиночный
промах шага временный: задача останется в своём состоянии, и следующий тик
повторит. Тот же класс сбоя в синхронной операции приёма — `ERROR`, потому что
операция провалилась целиком и повтора нет. Уровень задаёт не текст ошибки, а
наличие штатного повтора.
- Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о поведении
зависимости, а не дубль доменной ошибки.
- Глушить ошибку без лога — только с однострочным комментарием «почему».
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
записи.
## Внешние сервисы: логируем все вызовы
**Каждый** вызов внешнего сервиса логируется. Поля:
- `ext.service``speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
`PutObject`, `convert`);
- `ext.status_code` — код ответа, если применим;
- `duration_ms` — длительность вызова;
- `retry` — номер попытки, если повторы были.
Уровни вызова:
- `INFO` — успешный **событийный** вызов (заливка объекта, запуск распознавания,
отправка сообщения, конвертация);
- `DEBUG` — успешный **рутинно-частый** вызов (опрос готовности операции,
длинный опрос обновлений);
- `WARN` — попытка не удалась, делаем повтор;
- `ERROR` — повторы исчерпаны либо сервис недоступен. Завершённый ответ с 4xx —
это успех на транспортном уровне; решение «это ошибка» принимает доменный
вызывающий.
Тело запроса и ответа — только на `DEBUG` и **после** вычистки секретов.
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage и опрос операции не логируются никак.
## HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `http.path_length`, `transport`.
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
потребитель молча оставит одно из значений. Правило проверяется чтением,
линтером не выражается.
- **`GET /health` и `GET /metrics` логируем на `DEBUG`** — их дёргают
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся.
Расхождения здесь больше нет: слой журналирования запросов свой —
`internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
то есть при боевом `INFO` не пишутся вовсе.
**Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
## Безопасность: что не логируем
Никаких секретов в полях и сообщениях. Под запретом:
- ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
переписки. Логируем длину текста, а не текст. Имя файла ничем не заменяем:
ни укороченным именем, ни отпечатком от него — отпечаток та же приватная
величина, а корреляцию держат идентификаторы сущностей. Чем при этом
прослеживается приём, нормирует спека `intake`, а не эта запись.
Дополнительно:
- Тела запросов и ответов внешних сервисов — только на `DEBUG`, с вычисткой
секретов и обрезкой по длине.
- При сомнении не логируем значение, логируем факт его наличия
(`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
`*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
живёт прямо в пути. Такую ошибку разворачивают в
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
неверной.
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением. В журнал оно идёт **собственным полем** строки приёма — это
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
[../security.md](../security.md).
## Куда пишем и уровень
- Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение. Не
раскладываем по файлам.
- Базовый уровень в продакшене — `INFO`; `DEBUG` включается конфигом при
необходимости. При разработке — `DEBUG`.
*Расхождение:* поля конфигурации под уровень лога нет.
## Анализ
- Повседневно — `jq`: `jq 'select(.record_id=="…")' app.jsonl`.
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
+145
View File
@@ -0,0 +1,145 @@
# Веб-UI
Конвенция: *как* мы пишем код приложения. Это правила оформления кода (How), а не
спецификация поведения — что именно приложение показывает и какие действия
обязано поддерживать, живёт в спеке OpenSpec.
Фреймворк выбран 2026-08-11 разведкой `spa-framework-choice`:
[ADR](../adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
[research/spa-framework.md](../research/spa-framework.md). Прежняя редакция
описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята
целиком вместе со сменой решения на SPA 2026-08-10.
Правила ниже применены каркасом приложения (`spa-skeleton`, 2026-08-15): до него
они были выведены из выбора и из замера на пробном экране, а не из написанного
кода. Место, где правило разойдётся с тем, что окажется удобным, — повод править
эту запись, а не обходить её молча.
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
наружу — [errors.md](errors.md).
**Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в
команду сборки, а не стоит отдельным шагом. Форматирование и статический анализ
держит **Biome**, поведение экранов — **юнит-тесты Vue**; оба шага входят в набор
проверок наравне со сборкой. Инструменты зовутся контейнером, а не из `PATH`:
требованием к машине разработчика остаётся docker, а не установленный Node.
## Что решено про само приложение
- **Приложение — SPA**, а не страницы, отрисованные сервером. Сервер отдаёт
контракт данных, разметку собирает клиент.
- **Приложение ставится на телефон** и запускается с ярлыка: манифест, иконки,
service worker.
- **Статика вшивается в бинарник** через `go:embed` и раздаётся им же. Внешнего
веб-сервера под статику не заводим, бинарник остаётся самодостаточным.
- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени
выполнения нет.
- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница
из [паспорта](../passport.md). Без сети приложение показывает состояние, а не
пустой экран.
- **Web Push не делаем**: уведомления идут через apprise и ntfy — решение живёт
в [architecture.md](../architecture.md), «Уведомления», делает его
[ntfy-delivery](../../tasks/items/ntfy-delivery.md).
- **Записи звука в приложении не делаем** — граница из
[паспорта](../passport.md), «Диктофон»; файл выбирают системным диалогом.
## Фреймворк и сборка
- **Vue 3, TypeScript, сборка Vite.** Серверной отрисовки нет, надстройки над
фреймворком (Nuxt) нет: она ждёт рядом процесс Node, а у нас статика в
бинарнике.
- **Компонент — однофайловый, `<script setup lang="ts">`.** Options API не
пишем: два способа объявить компонент в одном приложении — второй способ
делать то же самое.
- **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает
Vite, руками их не задаём: от этого зависит обновление установленного
приложения. Сервис на это правило опирается, но проверить его не может — имён
он не выбирает, — поэтому дом правила здесь, а не в спеке: долгий срок
хранения он ставит **по каталогу** сборщика, и файл, положенный туда без
отпечатка в имени, останется в хранилище браузера навсегда.
- **Зависимости ставятся из файла замка командой, которая его не правит.** Иначе
набор проверок пачкает рабочее дерево, обновление зависимости приезжает в
коммит без чьего-либо решения, а собранное в наборе проверок перестаёт
совпадать с собранным в образе.
- **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики
роняет гейт наравне с `go build`.
## Маршруты
- **Одна таблица маршрутов** через `createRouter`. Маршруты по файлам не
включаем: сборочная надстройка роутера пятой версии стоит 34 пакета в
установке и на нашем числе маршрутов не окупается
([research/spa-framework.md](../research/spa-framework.md), «Vue»). Сколько
экранов и какие — не здесь: состав нормирует спека
[webapp](../../openspec/specs/webapp/spec.md).
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
`404`; путь внутри корня в приложение не проваливается никогда. Корень
сегодня **один**`/app/` у приложения, — плюс `/health` и `/metrics`
отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый
ушёл с собственным входом, два других — со встроенным хранилищем и его
панелью, и пути под ними стали обычными путями вне корней. Приложение уехало
из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило:
соседа, ради которого выбирался, больше нет, а формы запросов и ответов от
смены хранилища не изменились ни одним полем. Перечень корней сервису не
описывают, а из него **порождают** регистрацию маршрутов: описанный порознь,
он разошёлся бы с ними молча.
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
которому не нашлось файла, отвечает `404`. Правило — вторая половина
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
подменить их разметкой значит ответить `200` на то, чего нет. Браузер отвергнет
такой ответ по типу содержимого, человек увидит пустой экран, а в кодах
ответов сервиса не останется ничего.
- **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не
получает от предыдущего: приложение открывают по ссылке и обновляют страницу
посередине.
## Состояние и обращение к API
- **Состояние экрана живёт в экране** — `ref` и `computed` по месту. Общее между
экранами выносим в composable-функцию `use…`.
- **Хранилища состояния (Pinia) не заводим**, пока два экрана не потребуют одних
и тех же данных одновременно. Заведём — это правка этой записи с названной
причиной.
- **Обращение к API — через `fetch` и через одну свою обёртку.** Сторонних
клиентов (axios и подобных) не берём: внешних ресурсов у нас нет, а разбор
ответа и отображение ошибки всё равно свои.
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
не `Response`.
- **Сессии у сервиса нет вовсе**, и приложение не хранит ничего: кто пришёл,
называет заголовок обратного прокси, а приложение узнаёт его ответом API.
Кук сервис не ставит — это свойство сторожится проверкой. Норма —
[access](../../openspec/specs/access/spec.md), решение —
[ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md).
## Показ ошибок и состояний
- **Текст ошибки приходит с сервера и показывается как есть.** Своих текстов под
коды ответа приложение не сочиняет: единая форма ошибки — обязанность API
(спека [archive](../../openspec/specs/archive/spec.md)), и второй словарь
на клиенте разошёлся бы с первым.
- **Отсутствие связи — состояние, а не ошибка.** Сорванный запрос показывается
строкой «связи нет», а не пустым экраном и не сообщением браузера.
- **У каждого списка три состояния и все три нарисованы:** загружается, пусто,
есть данные. Пустой список без надписи неотличим от незагруженного.
- **Текст, который видит пользователь, — русский** ([CLAUDE.md](../../CLAUDE.md),
«Язык»). Код и идентификаторы английские, включая имена компонентов и файлов.
## Что не решено
- **Инструмент статического анализа проверен наполовину.** Biome взят решением
владельца 2026-08-15 и разбирает однофайловые компоненты; замены он потребует,
если перестанет их держать. Тогда это отдельное решение, а не подстановка по
ходу.
- **Проверка типов держится на пятой линии TypeScript.** С седьмой `vue-tsc`
не работает: новый компилятор не отдаёт точку входа, которую тот зовёт.
Проверено прогоном 2026-08-15.
- **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а
готовый способен удвоить собранный файл
([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не
узнала»).
- **Устройство service worker и версионирование статики** — задача
[installable-pwa](../../tasks/items/installable-pwa.md).
- **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа
«Учётные записи» в [../architecture.md](../architecture.md).
+452
View File
@@ -0,0 +1,452 @@
# Схема хранилища
База, таблицы, раскладка файлов, правило времени и идентификаторов.
Хранилище **своё**: база SQLite через `modernc.org/sqlite` (CGO сборке не нужен)
и файлы записей своим каталогом рядом с ней. Ключ конфигурации один —
`[storage] data_dir`, умолчание `data`. Встроенная PocketBase, державшая до
2026-08-22 и базу, и файлы, и панель, и маршрутизатор, ушла из проекта целиком —
задача `storage-without-pocketbase`,
[ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md).
**База принимает одного писателя.** Пишущий пул держит одно соединение — драйвер
пишет единственным, и несколько воркеров, пришедших писать разом мимо этого
правила, получают отказ по занятости на записи результата шага, то есть после
оплаченной работы. Чтение идёт отдельным пулом: в журнале упреждающей записи
читатели не мешают писателю.
Журнал упреждающей записи, соблюдение внешних ключей и ожидание занятой базы
задаются **строкой подключения обоих пулов**, а не запросом после открытия: две
из трёх настроек в SQLite принадлежат соединению, а не базе, а пул заводит новые
соединения по мере надобности — запрос настроил бы одно из многих. Операция,
которая читает и следом пишет, идёт целиком по пишущему соединению: читающую
транзакцию SQLite до пишущей не повышает и отказывает по занятости немедленно.
Схему двигают **шаги `github.com/pressly/goose/v3`** — библиотекой, а не
командной строкой. Каталог `internal/adapter/repo/sqlite/migrations`, файл на
шаг, версия шага — число в начале имени файла. Перечень шагов приходит
провайдеру доводом, провайдер заводится в точке входа и получает пишущий пул,
накат идёт **до подъёма входов и до старта воркеров**, а отказ шага роняет старт.
Применённый шаг не переписывается — изменение только новым шагом.
Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
соединении. Порядок шагов детерминирован и выводится из версии, а не из порядка
чтения каталога; две одинаковых версии дают отказ сбора.
**Исключающую блокировку наката держим сами.** Библиотека под SQLite её не
поставляет вовсе — её запиратели объявлены только для PostgreSQL, а провайдер без
запирателя накатывает без всякой блокировки. Замок берётся на файле
`data/migrate.lock` (`syscall.Flock`, `LOCK_EX`) и снимается закрытием
дескриптора; с умершим процессом его снимает ядро, поэтому просроченного замка,
который надо чистить руками, не остаётся.
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
пути**, а префикс наводится только на каталог. Где этот префикс задан —
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано».
**Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита
Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной
миллисекунды выдача монотонна, потому что колонка времени несёт секунды и
порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи,
разбирается на границе: разбор проверяет вид и приводит регистр, а негодный
считается несуществующей записью и до базы не доходит.
Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя
файла внутри него — `<ULID><расширение>`.
**Время**`TEXT` в RFC 3339, UTC, суффикс `Z`, секундная точность:
`2006-01-02T15:04:05Z`. Ширина записи постоянная, поэтому лексикографический
порядок совпадает с хронологией. Вид один на **все** колонки времени, включая
те, что пишет только сам сервис: своего типа времени у SQLite нет, колонка
хранит то, что в неё положили, и колонка, заполненная то одним видом, то другим,
обратила бы условие срока протухания захвата в постоянную истину или ложь молча.
Время ставит приложение единой точкой `internal/clock`. **Умолчаний вида
`CURRENT_TIMESTAMP` в схеме нет**: умолчание писало бы свой вид времени, а
вставка, забывшая проставить время, при нём прошла бы молча.
## Таблицы
**Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки и
вид текста были закрыты `CHECK`-подобным типом хранилища, потому что панель
владельца правила запись руками и вправе была завести значение, которого сервис
не знает. Панели нет, правка идёт только нашим кодом, и закрытый перечень в схеме
остался бы ценой — новое значение стоило бы нового шага — без покупателя.
### `users`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID, выдаёт приложение |
| `provider_login` | TEXT, уникален | Логин человека **у провайдера**: то значение, которым его называет обратный прокси заголовком `Remote-User`. Ключ учётной записи |
| `name` | TEXT | Имя, пригодное к показу; берётся при заведении и вторым обращением не переписывается |
| `email` | TEXT | Адрес почты; необязателен |
| `created_at`, `updated_at` | TEXT | Время |
Уникальность почты держится **частичным** индексом (`WHERE email <> ''`), поэтому
записи без почты уживаются друг с другом. Уникальность логина — обычным.
Ключом почта не служит вовсе: адрес меняется, и первое обращение с чужим адресом
досталось бы чужой записи.
### `files`
Одна строка на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
считается — она существует только потому, что провайдер распознавания читает
аудио по адресу, и её ключ живёт в строке попытки распознавания.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает |
| `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога |
| `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис |
| `size_bytes` | INTEGER | Размер копии в байтах |
| `format` | TEXT | Расширение без точки, в нижнем регистре |
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
| `created_at` | TEXT | Время |
**Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит
файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо
раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец
при этом лежит своей колонкой, а не выводится через запись: файл переживает свою
запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки.
### `audio_records`
Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на
приложения лежат здесь; содержимое — по ссылкам, отдельными строками.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `owner_id` | TEXT → `users(id)` | Владелец записи; пустого значения не принимает |
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `original_filename` | TEXT | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
| `duration_ms` | INTEGER, обязателен | Длительность **принятого**, миллисекунды; ставит приём и всегда |
| `size_bytes` | INTEGER, обязателен | Размер **принятого**, байты |
| `state` | TEXT | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done` |
| `state_entered_at` | TEXT | Время входа в рубеж — сторож застревания |
| `halted_at` | TEXT | Признак остановки; рубеж при ней не стирается |
| `halt_reason` | TEXT | `step_failed`, `attempts_exhausted`, `stuck` |
| `error_text` | TEXT | Текст ошибки, машинный |
| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
| `acquire_expires_at` | TEXT | Срок протухания захвата; приезжает с рубежом |
| `delay_time` | TEXT | Не брать запись раньше этого времени |
| `attempts` | INTEGER | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
| `original_file_id` | TEXT → `files(id)` | Принятая копия |
| `normalized_file_id` | TEXT → `files(id)` | Копия, приведённая к рабочему формату |
| `transcript_text_id`, `literary_text_id` | TEXT | Тексты записи |
| `structure_id` | TEXT | Структура реплик |
| `recognition_id` | TEXT | Попытка распознавания |
| `created_at`, `updated_at` | TEXT | Время |
Индексов два. `idx_audio_records_acquire``(state, halted_at, created_at, id)`:
по нему идёт отбор захвата, и по нему же он берёт запись в определённом порядке.
`idx_audio_records_owner_page``(owner_id, created_at, id)`: под страницу
списка, сужаемую владельцем и режущуюся полным ключом сортировки.
Оба индекса заведены **начальным шагом**, а не отложены: применённый шаг схемы не
переписывается, и добавление индекса стоило бы отдельного шага. Проверено
`EXPLAIN QUERY PLAN`: ни отбор захвата, ни страница списка не показывают полного
сканирования таблицы.
**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
страница сканировала таблицу целиком и досортировывала результат во временном
дереве, а рост архива с 5 тысяч строк до 200 тысяч растил время одной страницы
владельца в двадцать-тридцать раз — при неизменных сорока его собственных
записях. Цена росла с **чужими** записями, потому что сервис объявлен архивом и
хранит их бессрочно.
**Имя файла и заголовок — разные колонки.** Заголовок несёт название, которое
дал человек либо посчитала языковая модель; имя файла — то, по чему человек
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
файла на диске и в журнал оно по-прежнему не идёт.
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
приёмом один раз; на файле — величины нынешней копии. Уточнение длительности
меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и
«что лежит сейчас». Колонками записи они нужны потому, что показываются в списке,
а список читается без содержимого. Решение владельца от 2026-08-15.
**«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе
величины ставит приём и ставит всегда — запись с непрочитанными метаданными
отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое
значение, которое схема теперь допустить может, завело бы третий смысл, которого
никто не читает.
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
хранилище, и принятого человеком файла не найти было ничем.
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
признака, — и различие между ними перестало быть структурным.
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
справлялось ни с одной.
### `record_topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `record_id` | TEXT → `audio_records(id)` | Запись |
| `topic_id` | TEXT → `topics(id)` | Тема |
Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**:
без него часовой разговор даёт два десятка тем, и словарь распухает за неделю.
Число берётся у домена — то же самое, которое сервис объявляет приложению.
### `texts`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка |
| `kind` | TEXT | `transcript` или `literary` |
| `contents` | TEXT | Сам текст |
| `created_at`, `updated_at` | TEXT | Время |
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
файла.
### `structures`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это структура |
| `version` | INTEGER | Версия вида разбора |
| `contents` | TEXT | Реплики со временем, JSON |
| `created_at`, `updated_at` | TEXT | Время |
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
сохранённого ответа изменится раньше, чем архив пересчитают.
### `recognitions`
Попытка распознавания у внешнего провайдера — всё, что зависит от него.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это попытка |
| `provider`, `model` | TEXT | Кем и какой моделью считано |
| `external_id` | TEXT | Идентификатор операции у провайдера |
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
| `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера |
| `started_at`, `finished_at` | TEXT | Границы операции |
| `created_at`, `updated_at` | TEXT | Время |
**Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.**
Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку
целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при
каждом опросе. Хранится он потому, что результат операции у провайдера не
переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему
две, — и адреса, которым его читают снаружи, у сервиса нет вовсе.
### `record_events`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чьё это событие |
| `origin` | TEXT | `pipeline` или `human` |
| `step` | TEXT | Имя шага |
| `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` |
| `outcome_text` | TEXT | Причина, если она есть |
| `duration_ms` | INTEGER | Сколько шаг занял |
| `created_at` | TEXT | Время |
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
двусмысленным.
Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки,
возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у
журнала не осталось.
### `topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `owner_id` | TEXT → `users(id)` | Чей это словарь |
| `name` | TEXT | Название темы |
| `created_at`, `updated_at` | TEXT | Время |
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен
целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего
сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача,
считающая темы, не платила вторым необратимым шагом схемы.
### Чего в схеме больше нет
**Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг
начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта
«применённая миграция не переписывается», решением владельца от 2026-08-22:
стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база
ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не
годятся вовсе. Снятие кончается этим шагом.
**Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением
и не читались никем: в `location` уходило `local`, второго значения (`s3`) не
писал ни один шаг; в `source` всякий приём писал `api`, а второе значение
(`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги
ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся
одним новым шагом схемы.
**Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же
причине:** их держал применённый шаг, которого больше не существует.
**Учётная запись с записями не удаляется**, и держит это схема обязательной
связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются
на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом
соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая
его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись
удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный
тупик до задачи про удаление записи.
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
колонки шаг читает отдельным чтением.
- **Файлы записи лежат подкаталогом на запись:**
`data/records/<ULID записи>/<имя>`. Внутри — принятая копия, приведённая копия
и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись
убирается целиком одним движением; плоский каталог, где копии различаются
приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное
отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни
объекты в Object Storage не удаляются после завершения записи: каталог и бакет
растут неограниченно.
- **Укладка атомарна:** содержимое пишется во временное имя **в том же
подкаталоге записи** и переименовывается в рабочее только после того, как поток
дочитан до конца без отказа. Строка о файле заводится **после** этого;
содержимое легло, а строка не сохранилась — уложенный файл убирается.
- **Файл отдаётся адресом приложения** —
`GET /app/audiorecords/{id}/file?copy=original|normalized`, — и право пройти по
нему даёт узнавание пришедшего и владение записью. Значений на предъявителя
сервис не выдаёт вовсе: ни короткого токена файла, ни подписанной ссылки со
сроком. Отзыв доступа доходит до файла сразу, а не через срок жизни выданного
значения. Имя файла на диске в журнал не пишется и в ответ не идёт.
- **Учётная запись заводится первым обращением** — поиск по `provider_login` и
вставка идут одной транзакцией на пишущем соединении. Два отказа уникальности
различаются повторным поиском по ключу: нашёлся — гонка двух первых обращений
одним логином, не нашёлся — занятая почта, и запись заводится без неё.
- **Захват записи — один запрос `UPDATE … RETURNING`** по пишущему соединению:
выбор подходящей записи и пометка её захваченной идут вместе. Порядок выборки —
по времени заведения **и по ключу**: время неуникально, и без ключа порядок
обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку
протухания захвата и отсутствию признака остановки; срок протухания выбирается
по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет,
подставить его не может.
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом
запросе правки, поэтому между проверкой и записью не остаётся окна.
- **Колонки записи отображаются по имени**: именованные параметры запроса и место
назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут
длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле,
который компилируется молча и кладёт идентификатор файла в колонку текста.
Перечень мест, где правится колонка, и серьёзность правила — инвариант
«Колонки записи правятся в трёх местах» в [CLAUDE.md](../CLAUDE.md),
«Инварианты»; сверку держат правила `internal/archrules`.
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику.
- **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют
запись её идентификатором и не несут ни имени файла, ни пути к нему: имя —
часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт
полный URL объекта.
- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса.
Отменять там нечего: операции местные и короткие, а единственное ожидание —
занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный
остановкой сервиса, перестал бы освобождать захват и писать причину остановки —
то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена,
которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного
распознавания.
## Настройки с числовым значением
| Настройка | Значение | Где | Откуда число |
| --- | --- | --- | --- |
| Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Пауза перед повтором | `2^(отказ−1)` с, потолок 5 минут | там же | то же |
| Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас |
| Срок захвата, отправка на распознавание | 8 часов | там же | то же |
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
| Ожидание занятой базы | 5000 миллисекунд | конфиг, `[storage] busy_timeout_ms` | выведено из числа воркеров, а не замерено: пишет сервис короткими операциями, и очередь из трёх воркеров укладывается в него с запасом |
| Соединений в читающем пуле | 4 | конфиг, `[storage] read_connections` | число воркеров плюс запас под запросы приложения; пишущее соединение при этом всегда одно и настройкой не делается |
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
| Срок жизни неиспользуемого счётчика ограничителя | 10 минут | `controller/http.staleBudgetAge` | карта счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда спрашивающий кладёт по строке на каждый свой адрес |
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
| Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
| Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу |
| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же |
| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID |
**Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда,
когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого
счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один
и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
ограничивает. Как читается цепочка — спека
[archive](../openspec/specs/archive/spec.md); здесь только числа бюджета.
Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца
панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии,
потолок времени на вход у провайдера и таймаут обмена кода.
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения
**срока захвата** её рубежа, то есть восьми часов у приведения и отправки.
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама.
**Потолок размера назван числом там, где иначе действует умолчание**у тела
запроса приёма, и назван дважды: объявленная длина судится заранее, а
необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а
величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть
часов записи по медленному каналу переживают любой фиксированный, а стойкость к
целенаправленной нагрузке объявлена вне модели угроз.
Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе.
Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.
+134
View File
@@ -0,0 +1,134 @@
# Паспорт проекта
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
устроено», [tasks/BACKLOG.md](../tasks/BACKLOG.md) — «в каком порядке», паспорт —
«зачем и для кого».
## Цель
Превращать записанную речь в текст, который можно читать и искать, и хранить
этот текст вместе с записью.
**Сервис — архив, и это решено 2026-08-11.** Прежде граница читалась «отдаём
текст и на этом заканчиваем»; теперь расшифровки и исходные записи лежат
бессрочно, а список отбирается по темам. Причина в основном сценарии: семейный
архив загружают один раз, а возвращаются к нему годами.
**Потребители** — список закрытый: он определяет, что считать нужным, а что
интересным.
| Кто | Что ему нужно от нас |
| --- | --- |
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня не работает вовсе: домен целиком стоит за обратным прокси, и запрос программы отбивает он, не доходя до сервиса. Токен и правило прокси мимо входа приносит `api-tokens` |
**Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
приложение: диктофонная запись на несколько часов через Telegram не проходит
вовсе. 2026-08-14 бот убран целиком — временно, до задачи, которая свяжет чат с
учётной записью. Вместе с ним из потребителей ушёл пользователь
Telegram.
Цель достигнута, когда:
- запись любого распространённого формата принимается без предварительной
подготовки, включая дорожку из видео;
- запись расчётного потолка — шести часов — доходит до текста, а не прерывается
ошибкой при достижении предела (норма — `openspec/specs/storage`);
- сервисом пользуются несколько человек, и записи одного не видны другому;
- текст доступен там же, где загружали. Человек узнаёт о его готовности, не
держа приложение открытым;
- расшифровка не теряется: к записи возвращаются через месяц и находят её по
заголовку и темам;
- владелец видит расход по каждому пользователю и понимает, во что обходится
приглашение ещё одного человека.
## Что целью не является
Граница домена. По ней в теме `architecture` судят, не перенесено ли понятие
через границу.
- **Правка текста руками.** Машинную вычитку расшифровки отдаём — литературный
текст стоит рядом с сырым, — а редактором не становимся: текст руками не
правим, не размечаем и не экспортируем в форматы документов. Граница сдвинута
2026-08-11: до того запрет читался «расшифровку отдаём как есть».
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него».
Считать уровни текста берётся задача
[llm-insights-adapter](../tasks/items/llm-insights-adapter.md).
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
провайдер, свою регистрацию и свои пароли не делаем. Своя строка учётной
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
имя, названное провайдером, — заводит строку при первом обращении под новым
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
сервис не решает никогда. Панель администратора со своим паролем владельца жила
здесь с 2026-08-11 по 2026-08-22 и ушла вместе со встроенным хранилищем.
*Изъятие одно:* при включённом предохранителе `[server] debug`, выключенном по
умолчанию, сервис подставляет запросу те заголовки входа, которые в бою даёт
обратный прокси. Своего входа, регистрации и проверки допуска он от этого не
заводит: подставленное имя проходит то же узнавание, что и пришедшее. Кого
пускать, провайдер решает во всяком прогоне без изъятия; в самом изъятии его не
спрашивают вовсе — сервис называет пришедшего сам. Тем изъятие и держится
выключенным умолчанием, а границу его держит спека
[access](../openspec/specs/access/spec.md).
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем.
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
файл. Своей записи и работы без сети не делаем.
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
речи в них. Складом произвольных файлов и папками сервис не становится. Общего
доступа к чужим записям целью нет, и с 2026-08-14 его нет и на деле: у записи
есть владелец, и чужую по её идентификатору не отдают
([security.md](security.md), «Периметр»). Закрыла это задача
`record-ownership`.
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
пользователя, потратившего слишком много, останавливает разговор или отзыв
доступа в Authelia.
## Типовые сценарии
Первые два — основные, и сегодня не работает ни один. Приложение с 2026-08-15
есть, но экранов у него пока нет: оно открывается и показывает вошедшего, а
загрузку и список заводят `upload-and-status-screen` и `records-list-screen`.
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
Загрузка показывает ход. Файл, который уже загружали, не грузится второй
раз. Когда текст готов, приходит уведомление; в списке запись видна
заголовком и темами.
2. **Возвращение к записи.** Через месяц человек открывает список, находит
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
расшифровку.
3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим
токеном, получает идентификатор записи и читает её карточку
`GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только тому, кого
назвал доверенный источник: неузнанный запрос всеми адресами отклоняется.
Своего способа представиться у программы нет — его заводит `api-tokens`.
Записи при этом разграничены: видны только записи того, чьим именем пришли.
4. **Отказ на середине.** Конвертация или распознавание не удались — запись
получает признак остановки с причиной, и карточка записи отдаёт признак и
причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт:
доставка ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
## Референсы
Где смотреть prior art, когда упёрлись.
- **Yandex SpeechKit, отложенное распознавание** — модель `deferred-general`,
которой пользуемся: она и задаёт потолок по длине записи и формату.
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
перестанет устраивать по цене или по качеству русской речи.
**PocketBase** побывала и референсом, и стеком, и ушла из проекта целиком.
Референсом она быть перестала 2026-08-12, когда задача `pocketbase-storage`
перевела её в стек; стеком — 2026-08-22, когда задача
`storage-without-pocketbase`
([adr](adr/ADR-2026-08-22-storage-without-pocketbase.md)) убрала её вместе с
панелью владельца и собственным адресным пространством. Хранилище у сервиса своё:
SQLite напрямую и файлы записей своим каталогом. Схема и раскладка —
[database.md](database.md).
+38
View File
@@ -0,0 +1,38 @@
# Разведка
Наблюдения за внешним миром: что реально шлёт источник, чем документация формата
расходится с практикой. Источник истины — этот каталог, а не чужая документация.
**Каждый вывод — с числами и командой, которой получен**, чтобы его можно было
перепроверить.
## Как снималось
На живом потоке не снималось ничего: поведение внешних сервисов на границах не
проверяли. Все записи сделаны в песочнице — на пустой базе либо в каталоге вне
репозитория.
Внешних источников, о которых разведка нужна, четыре — Telegram Bot API, Yandex
SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, что стоит
открытыми вопросами в [../architecture.md](../architecture.md) — «Долгие
записи», «Формат для распознавания», «Видео». Первый же ответ на любой из них
заводит здесь запись с командой и условиями замера.
## Записи
Две записи о PocketBase — [pocketbase.md](pocketbase.md) и
[pocketbase-defaults.md](pocketbase-defaults.md) — описывают библиотеку, ушедшую
из проекта 2026-08-22. Они остаются записями о прошлом, и строкой в каждой это
сказано.
| Дата | Запись | О чём |
| --- | --- | --- |
| 2026-08-23 | [Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина](toml-unknown-keys.md) | `err == nil` на опечатке в имени секции, потерянное только в `MetaData.Undecoded()`, безопасное направление отката в BurntSushi/toml v1.5.0 |
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
| 2026-08-15 | [Раздача приложения: что делают за нас библиотека и сборщик](webapp-serving.md) | Раскодированный путь у маршрутизатора, второй журнал у PocketBase, нулевое время у вшитого файла, зависание установщика без сети |
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
| 2026-08-11 | [Очередь задач: своя таблица против готовой библиотеки](job-queue.md) | Цена River и goqite в пакетах, захват одним запросом, чего нет для PocketBase |
| 2026-08-11 | [PocketBase: что даёт панель администратора](pocketbase.md) | Записи, пользователи и файлы в панели версии 0.39.10 |
+41
View File
@@ -0,0 +1,41 @@
# gRPC-клиент SpeechKit: когда закрытие вообще может отказать
Отвечает на вопрос, возникший по ходу задачи `errors-as-instead-of-typecast`: что
означает отказ `Close` у клиента SpeechKit и стоит ли писать его в журнал.
Наблюдение понадобилось потому, что первая редакция кода и обоснования описывала
этот отказ неверно — как признак недоступности Yandex.
## Как снималось
Не замером, а **чтением исходников** зависимости, зафиксированной в `go.mod`:
`google.golang.org/grpc` версии **v1.74.2**. Смотрел два места в
`clientconn.go` — конструктор клиента и метод `Close`. К Yandex ни разу не
обратился: ни на живых ключах, ни на тестовых.
## Что выяснилось
- **`grpc.NewClient` соединения не открывает.** Клиент создаётся в состоянии
ожидания, сеть трогается при первом вызове (`clientconn.go:145`). То есть на
пути отказа конструктора — когда первый клиент создан, а второй нет — закрывать
ещё нечего.
- **`(*ClientConn).Close` возвращает ровно два исхода** (`clientconn.go:1142-1156`):
`nil` либо `ErrClientConnClosing``codes.Canceled`, «grpc: the client
connection is closing» (`clientconn.go:67`). Второй наступает **только при
повторном закрытии** уже закрытого клиента.
## Что из этого следует для нас
Отказ `Close` в этом проекте означает **нашу ошибку — закрыли дважды**, а не сбой
или недоступность Yandex. Поэтому запись в журнале при остановке процесса
адресует владельца к нашему коду; так она и сформулирована.
Обработка отказа при этом оставлена в обоих местах, хотя сегодня он практически
недостижим: она стоит одну строку и переживёт смену клиента, а её отсутствие
пришлось бы обосновывать заново каждому читателю. Решение и его цена —
[ADR](../adr/ADR-2026-08-11-errcheck-check-blank.md), обоснование целиком — в
архивном
[design.md](../../openspec/changes/archive/2026-08-11-errors-as-instead-of-typecast/design.md),
Решение 2.
**Наблюдение привязано к версии.** Сменится мажорная версия `grpc` — перечень
исходов `Close` надо перечитать, а не считать его прежним.
+141
View File
@@ -0,0 +1,141 @@
# Очередь задач: своя таблица против готовой библиотеки
Отвечает на вопрос разведки `job-queue-choice`: брать ли готовую очередь на Go
поверх той же встроенной базы или оставить свою таблицу, дописав к ней повторы,
счётчик попыток и очередь мёртвых задач. Разведка шла перед `pocketbase-storage`,
потому что смена хранилища переписывает захват задачи в любом случае.
Внешнего брокера — Redis, RabbitMQ, NATS — не рассматривали по рамке задачи: он
добавляет к выкладке процесс, которого там нет, ради нагрузки в единицы записей
в день.
## Как снималось
Дата замеров — 2026-08-11. Всё считал в каталоге вне репозитория, который
удалён вместе с песочницей; боевые данные не участвовали.
- **Цена зависимости.** Завёл пустой модуль на Go 1.24 с одним PocketBase
0.39.10, затем его копии с добавленной библиотекой. Пакеты в сборке —
`CGO_ENABLED=0 go list -deps .`, модули в графе — `go list -m all`.
- **Захват одним запросом.** Программа на 40 строк в той же песочнице: таблица
из одной строки, три горутины разом выполняют один и тот же запрос
`UPDATE … WHERE id = (SELECT … LIMIT 1) RETURNING …`. Драйвер —
`modernc.org/sqlite` v1.55.0, тот самый, которым ходит в базу PocketBase,
режим журнала WAL, таймаут занятости 5 секунд.
- **Свойства библиотек** взяты из их документации, а не замерены: пометки
«объявлено» ниже стоят именно там.
- **Холостой опрос** не мерил, а посчитал: три воркера и пауза 1 секунда из
[../database.md](../database.md), «Настройки с числовым значением», дают
3 × 86 400 = **259 200 запросов к базе в сутки** независимо от того, есть ли
работа.
## Готовой очереди для PocketBase на Go нет
Проверил по списку экосистемы `awesome-pocketbase` и по обсуждениям в
репозитории PocketBase. Единственная очередь в списке — `pocketbase-queue`,
написана на TypeScript и работает из JS-хуков; из Go её не подключить. Она
заводит три коллекции (`queue_tasks`, `queue_locks`, `queue_stats`), упавшие
задачи держит с текстом ошибки семь дней и объявляет 50–60 задач в секунду на
четырёх воркерах. Ни нарастающей паузы, ни счётчика попыток у неё нет.
Автор PocketBase в обсуждении № 2101 советует ровно свою коллекцию с полями
«имя, данные, состояние» и обход её по расписанию, а про встроенную очередь
говорит: «очередь писем, а может и общая очередь задач, есть в моих планах, но
пока приоритет низкий». Планировщик у PocketBase свой, `app.Cron()`.
Отсюда разрез сравнения: выбор идёт не между готовым и своим, а между **своим в
коллекции PocketBase** и **чужой очередью, живущей рядом с PocketBase и мимо её
панели**. River и goqite про PocketBase не знают.
## Захват чинится одним запросом
Захват **на момент замера** — два запроса подряд без транзакции; после перехода
на PocketBase он свернулся в один с `RETURNING`
[../database.md](../database.md), «Представление данных». Замер показал, что
после перехода на PocketBase он сворачивается в один: движок за
`modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх
горутинах разом запись получила **ровно одна**.
Это снимает главный довод в пользу чужой библиотеки: транзакционность захвата
покупается одной строкой запроса, а не новой зависимостью.
## Кандидаты
| | Своя таблица коллекцией | River 0.43.0 | goqite 0.4.0 |
| --- | --- | --- | --- |
| Пакетов в сборке сверх PocketBase | 0 | 46 | 3 |
| Модулей в графе сверх PocketBase | 0 | 18 | 7 |
| Требует CGO | нет | нет | нет |
| Видна в панели PocketBase | да, правится | нет | нет |
| Повторы с нарастающей паузой | писать | есть | нет |
| Счётчик попыток | писать | есть | есть, предел выдач |
| Очередь мёртвых задач | писать | есть, состояние «отброшена» | нет |
| Ожидание без траты попытки | писать | есть | нет |
Числа зависимостей: с одним PocketBase в сборке 122 внешних пакета и 72 модуля
в графе; с River и его драйвером SQLite — 168 и 90; с goqite и его пакетом
задач — 125 и 79. Ни в одной сборке `mattn/go-sqlite3` не участвует: у goqite он
значится в графе, но только как зависимость его собственных проверок, и при
`CGO_ENABLED=0` всё три варианта собираются.
### River
Драйвер SQLite (`riverdriver/riversqlite`) появился в версии 0.23.0 и авторами
объявлен опытным: «схема ещё может быть слегка изменена, прежде чем её сочтут
окончательной». Объявленная скорость — четверть от той, что даёт Postgres, около
10 000 задач в секунду; для единиц записей в день это запас, которым мы не
воспользуемся. Свою веб-панель River даёт встраиваемым обработчиком, отдельного
процесса она не требует.
Ложится на нашу задачу River лучше всех по одному месту: ожидание операции
SpeechKit длиной до суток выражается его отложением, и попытка при этом не
тратится. Всё остальное против:
- очередь становится **цепочкой задач вместо состояния в таблице**, а это
переписывание `internal/service`, а не хранилища;
- свои таблицы River заводит сам, и панель PocketBase их не покажет: она знает
только свои коллекции. Показать их можно коллекцией-представлением, и та
**только для чтения** — повторить мёртвую задачу из панели не выйдет;
- панелей становится две, и у второй свой вход, который тоже надо закрывать на
обратном прокси;
- документация советует пул в одно соединение, чтобы не ловить отказ по
занятости, — поверх файла, который уже держит PocketBase.
### goqite
Самая дешёвая по зависимостям и самая бедная по существу. Сообщение — двоичное
тело в одной колонке: в панели оно нечитаемо. По умолчанию срок невидимости 5
секунд и предел выдач 3; нарастающей паузы нет, очереди мёртвых задач нет —
исчерпавшее предел сообщение просто перестаёт выдаваться. Это молчаливая потеря
принятой записи, а она запрещена инвариантом «Принятая запись не теряется молча»
([../../CLAUDE.md](../../CLAUDE.md), «Инварианты»). То есть счётчик попыток и
очередь мёртвых пришлось бы дописывать и поверх goqite — ровно то, ради чего
разведка затевалась.
## Что решено и от чего отказались
Решение — **своя таблица, но коллекцией PocketBase**: захват одним запросом с
`RETURNING`, счётчик попыток колонкой, нарастающая пауза через существующий
`delay_time`, состояние «мертва» вместо `is_error = 1`. Записано в
[ADR-2026-08-11-queue-as-pocketbase-collection](../adr/ADR-2026-08-11-queue-as-pocketbase-collection.md).
Отвергнуты:
- **River с драйвером SQLite** — покупает повторы, счётчик и мёртвых готовыми, но
выносит очередь из панели PocketBase, ради которой хранилище и переезжает, и
переписывает конвейер в цепочку задач. Опытный драйвер со сменной схемой
добавляет к этому обязанность следить за чужими миграциями;
- **goqite** — не отвечает ни на один из трёх вопросов задачи целиком, а его
предел выдач молча теряет запись;
- **`pocketbase-queue`** — на TypeScript, из Go не подключается.
## Чего разведка не узнала
- **Сколько стоит написать недостающее.** Объём работы по повторам, счётчику
попыток и мёртвым задачам не оценивался: он входит в
`pocketbase-storage`, которая переписывает репозиторий целиком.
- **Ложится ли суточное ожидание операции SpeechKit на River без сюрпризов.**
Проверка стоит написания кода, а выбранному способу она не нужна вовсе.
- **Нужен ли отказ от холостого опроса.** 259 200 запросов в сутки посчитаны, а
во что они обходятся на файле базы — нет. Процесс один, и разбудить воркер
внутри него можно каналом, но задачи на это нет.
+139
View File
@@ -0,0 +1,139 @@
# PocketBase: умолчания, которые ломают штатный сценарий
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Умолчания ниже принадлежат ушедшей библиотеке и ни на что в сервисе не влияют.
Живое из записки переехало в [../database.md](../database.md), «Настройки с
числовым значением», — потолок размера одной записи, потолок тела запроса и
снятый таймаут чтения, — и в
[ADR-2026-08-15-owner-required-by-schema](../adr/ADR-2026-08-15-owner-required-by-schema.md).
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить.
Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха:
объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а
значит обратное.
## Как снималось
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее
наблюдение — 2026-08-15.
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
`&core.FileField{MaxSize: 0}` читается библиотекой как её собственное умолчание:
```
core/field_file.go:28 const DefaultFileFieldMaxSize int64 = 5 << 20
core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
```
Проверено укладкой: файл в 6 МиБ отвергается на сохранении записи —
`the maximum allowed file size is 5242880 bytes`. Прогон через боевой роутер дал
границу дословно:
| тело запроса | ответ |
| --- | --- |
| 4 194 304 байта | `201` |
| 5 238 784 байта | `201` |
| 5 246 976 байт | `500` |
| 34 603 008 байт | `413` |
**Цена для сервиса:** 5 МиБ — это примерно 5,5 минут mp3 при 128 кбит/с. Отвергалась
бы не только длинная запись на приёме: результат конвертации в ogg переваливает
тот же порог примерно на пятой минуте, и **уже принятая** задача исчерпывала бы
попытки на шаге конвертации.
## Тело запроса режется на 32 МиБ раньше обработчика
`apis/base.go:36` вешает `BodyLimit(DefaultMaxBodySize)` на **корневой** роутер,
то есть и на чужие маршруты; `apis/middlewares_body_limit.go:14`
`const DefaultMaxBodySize int64 = 32 << 20`. Ответ `413` уходит мимо обработчика,
без строки в журнале приёма (последняя строка таблицы выше).
Снимается на маршруте: `.Bind(apis.BodyLimit(<своё число>))`.
## Таймаут чтения запроса — пять минут
`apis/serve.go:151` ставит `ReadTimeout: 5 * time.Minute`. Заливка шестичасовой
записи по медленному каналу его переживает: соединение рвётся на середине.
Снимается в хуке `OnServe``se.Server.ReadTimeout = 0`.
## Суффикс к имени файла дописывает конструктор, а не укладка
Первая записка наблюдала `sample.ogg → sample_uztrv6wvz3.ogg` и читала это как
свойство хранилища. Наблюдение верно **только когда имя строит сама библиотека**:
десять случайных знаков добавляет `normalizeName`, вызываемый из
`filesystem.NewFileFrom*`. Имя, положенное в поле `File.Name` после
конструктора, ложится на диск дословно:
```
задано 11111111-2222-3333-4444-555555555555.mp3
на диске 11111111-2222-3333-4444-555555555555.mp3
```
**Цена:** тот, кто задаёт имя сам, не получает от суффикса никакой
неугадываемости — и защищать ссылку на файл ему приходится другим.
## Хук правки записи не различает, кто пишет
`app.OnRecordUpdate(<коллекция>)` — событие **модели**: оно срабатывает на каждом
`app.Save`, включая сохранение из собственного кода. Хук, написанный «для
панели», правил записи конвейера: проверено прогоном — задержка, поставленная
шагом вместе со сменой состояния, обнулялась тем же сохранением.
Различает источник `app.OnRecordUpdateRequest(<коллекция>)`: оно поднимается
только на правку запросом, а код, пишущий мимо HTTP-слоя, под него не попадает.
## Приглашение завести владельца панели живёт полчаса
`apis/installer.go:31``systemSuperuser.NewStaticAuthToken(30 * time.Minute)`;
печатается только пока владельца нет (`needInstallerSuperuser`). Проверено
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
владельца при следующем запуске её нет.
## Обязательность связи проверяется у записи, а не у колонки
`Required` у поля связи — правило **проверки записи при сохранении**, а не
ограничение таблицы. Шаг схемы, объявляющий колонку обязательной на базе, где уже
лежат строки с пустым значением, проходит **зелёным** и такие строки оставляет:
```
core/field_relation.go:156 ColumnType отдаёт TEXT DEFAULT '' NOT NULL — от Required не зависит
core/collection_validate.go ни одной проверки, читающей существующие строки
```
Проверено прогоном 2026-08-15 на копии хранилища во временном каталоге: строка с
пустым владельцем заведена до шага, шаг применён тем же кодом, что и на подъёме,
и вывод:
```
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
ПОСЛЕ ШАГА: строка на месте, owner=""
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
```
Следствие для нас: оставленная строка становится **незакрываемой**. Захват идёт
сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение отказывает —
включая то, которым ставится признак остановки. Искать такие строки надо запросом
до выкладки, а не прогоном самого шага: прогон чистую базу от грязной не
отличает. Цена решения записана в
[adr/ADR-2026-08-15-owner-required-by-schema.md](../adr/ADR-2026-08-15-owner-required-by-schema.md).
## Чего эта записка не узнала
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
шестичасовой записи с запасом на видео, а не замером: настоящего распределения
длин у сервиса нет.
- **Как ведёт себя укладка файла в несколько гигабайт.** Самая длинная проверенная
запись — 9,6 МБ (десять минут mp3). Потоковую укладку это подтверждает, предел
— нет.
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
тестом на одной машине, не живой нагрузкой.
- **Сколько строк с пустой связью выдерживает смена признака обязательности.**
Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не
её цена на объёме.
+242
View File
@@ -0,0 +1,242 @@
# PocketBase: что даёт панель администратора
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Панели у сервиса нет, и ничто из описанного ниже сегодня не работает. Записка
остаётся затем, что ею мерили цену потери: возврат остановленной записи в работу
делает подкоманда `cmd/devtools resume`, а остальное приносят отдельные задачи.
Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
держать перевод хранилища в планах.
## Как снималось
Версия **0.39.10**, выпуск от 2026-07-30 (`./pocketbase --version`). Смотрел на
пустой базе в каталоге вне репозитория, боевые данные не участвовали. Прогонов
было два:
- **готовый бинарник** — `pocketbase serve --http=127.0.0.1:8099`, суперпользователь
заведён командой `pocketbase superuser create`. Возможности панели снимал её же
запросами (`/api/collections`, `/api/logs`, `/api/backups`, `/api/crons`,
`/api/settings`) и поиском по её собранному коду;
- **своя сборка**, где PocketBase подключён библиотекой к пустому приложению на
Go, — так, как предполагает задача `pocketbase-storage`.
Оба прогона удалены вместе с песочницей.
## Правка записей — работает целиком
Панель показывает каждую коллекцию таблицей, отбирает записи своим языком
фильтров, сортирует, создаёт, правит и удаляет их по одной. Сверх таблицы в ней
есть выгрузка списка в CSV, журнал запросов с временем ответа и кодом, резервные
копии с загрузкой и восстановлением, список заданий планировщика.
Групповой операции над отмеченными записями в панели нет: удаление идёт по
одной. Проверял поиском по её коду — строк вида «удалить отмеченное» в нём не
нашлось, тогда как «Export as CSV» и «Download JSON» нашлись.
## Пользователи — только те, кого туда положат
Панель показывает свою коллекцию пользователей и ничего больше. Отсюда следствие
для целевого входа: **пользователи Authelia в панели не появятся, если вход
делает само приложение**. Пустая база заводит шесть коллекций, из них одна
пользовательская (`users`) и пять служебных, включая `_externalAuths` — связь
записи с внешним провайдером.
Второй путь есть, и он работает: **вход можно отдать самой PocketBase**. У
пользовательской коллекции настраивается провайдер `oidc` с произвольными
адресами; я включил его на адреса вида `https://auth.example.com/api/oidc/...`,
и клиент немедленно стал получать провайдера в списке способов входа. Тогда
учётные записи заводятся сами, и панель их видит.
**В саму панель Authelia не пускает.** Вход суперпользователя — своя почта и свой
пароль:
- включить `oidc` у коллекции суперпользователей не удалось: запрос принимается,
но возвращает коллекцию с выключенным `oauth2`;
- включить второй фактор у неё же не удалось тоже — ответ `403`.
Ограничить панель списком адресов можно: настройка `superuserIPs` принимает
адреса и подсети. **Ею же можно запереть себя** — после того как я поставил туда
чужой адрес, все запросы суперпользователя, включая запрос на сброс настройки,
стали отвечать `403`. Команды сброса в наборе нет: он состоит из `migrate`,
`superuser`, `update` и `serve`.
## Файлы — только свои
Файл живёт полем записи, и раскладку на диске выбирает PocketBase:
```
pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>.ogg
pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>.ogg.attrs
```
Проверено загрузкой файла в 200 КБ: имя `sample.ogg` превратилось в
`sample_uztrv6wvz3.ogg`, рядом лёг файл атрибутов.
*Уточнено 2026-08-12:* суффикс дописывает конструктор имени, а не укладка. Имя,
заданное после конструктора, ложится на диск дословно — см.
[pocketbase-defaults.md](pocketbase-defaults.md).
Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь она
покажет строкой — прослушать и скачать запись по ней нельзя. Способа сослаться
на файл, уже лежащий на диске мимо её каталога, нет.
Поле помечается защищённым, и тогда файл не отдаётся по прямой ссылке: без токена
ответ `404`, с выданным файловым токеном — `200`.
**Резервные копии накрывают ровно её каталог.** Файлы, оставленные снаружи, в них
не попадут — то есть панель и встроенное резервное копирование покупаются одной и
той же ценой.
## Побочное: CGO уходит
Библиотечная сборка встала при `CGO_ENABLED=0` — PocketBase ходит в SQLite через
`modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано
сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает.
*Уточнено 2026-08-12:* перевод состоялся, и требования CGO в стеке больше нет —
[../../CLAUDE.md](../../CLAUDE.md), «Стек»: компилятор C нужен только детектору
гонок в гейте.
Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения
(`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота,
ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода,
не замерялось.
Панель отдаётся по адресу `/_/` того же порта, что и остальное приложение, — и в
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
на `/_/` кодом `200`.
## Вход через OIDC: что выяснилось при реализации
Дописано 2026-08-12 задачей `oidc-login`. Все находки ниже получены одним
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
против настоящего хранилища на временном каталоге — в ходе ревью того change. Живой Authelia в прогонах не
было ни разу: провайдера подменял свой `httptest`-сервер.
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
`CreateRule = ""` (создание доступно анониму) и `PasswordAuth.Enabled = true`
(`migrations/1640988000_init.go`, `core/collection_model_auth_options.go`).
Прогон подтвердил: `POST /api/collections/users/records``200`, следом
`auth-with-password``200` с токеном. То есть закрытие API за вход обходится
двумя запросами, пока эта поверхность не закрыта своим шагом схемы.
**Правило создания нельзя закрывать полностью.** `CreateRule = nil` означает «только
суперпользователь», а запись при первом входе заводит **внутренний** запрос
самого обмена, идущий без таких прав (`apis/record_crud.go`: проверка
`!hasSuperuserAuth && collection.CreateRule == nil`). Прогон: с `nil` вход
кончался `401`, учётных записей `0`. Работает правило
`@request.context = "oauth2"` — контекст ставит сам обмен
(`core.RequestInfoContextOAuth2`), а посторонний запрос приходит с контекстом по
умолчанию. Открывать правило пустой строкой при этом нельзя: публичный обмен
принимает поля создаваемой записи от вызывающего.
**Обмен кода наружу не экспортирован.** Пакет `apis` отдаёт ошибки, middleware,
`NewRouter`, `Serve` и обёртки; сам обмен — неэкспортированная функция за
маршрутом `POST /api/collections/{c}/auth-with-oauth2`, принимающая `provider`,
`code`, `codeVerifier`, `redirectURL`. Собственный `/api/oauth2-redirect` служит
другому — он ищет клиента realtime-подписки по параметру `state`, то есть
обслуживает всплывающее окно JS-клиента, а не серверный вход.
**`apis.NewRouter` не идемпотентна: собирать её нужно один раз и держать, а не
создавать заново при каждом вызове.**
Она зовёт `bindRealtimeEvents` и `bindUIExtensions`, а те вешают девять
обработчиков **на приложение** и без поля `Id`; `hook.Bind` такому генерирует
новый идентификатор и **добавляет**. Замер: пять вызовов подряд подняли
`OnModelAfterUpdateSuccess` с 4 до 14, а 3000 вызовов — время сотни сохранений
записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. Освобождения нет, только
перезапуск.
**Связывание учётной записи идёт по `sub`, а не найдя — по почте.** Обмен ищет
запись в `_externalAuths` по `providerId`, и лишь затем `FindAuthRecordByEmail`
(`apis/record_auth_with_oauth2.go`). Отсюда цена открытой регистрации: запись,
заведённая посторонним на чужой адрес почты, достаётся первому же настоящему
входу с этим адресом.
**Защищённое поле файла судится двумя вещами сразу** — коротким токеном файла из
строки запроса **и** правилом просмотра коллекции (`apis/file.go`). Незаданное
правило означает «только суперпользователь», поэтому одной пометки `Protected`
мало: прогон показал `404` анониму, вошедшему кукой, вошедшему заголовком и
вошедшему с законно полученным токеном файла — пока правило не назначено.
**Сессия по умолчанию продлеваема бессрочно.** Токен несёт поле
`refreshable=true`, и `POST /api/collections/{c}/auth-refresh` меняет его на
новый с новым сроком. Прогон: три продления подряд, каждое `200`, `exp` растёт.
Настройки «выдавать непродлеваемую сессию» у коллекции нет — закрывается только
слоем приложения поверх маршрута.
**Подпись сессии считается от секрета коллекции и ключа записи**, обе величины в
базе (`core/record_query.go`, `FindAuthRecordByToken`). Отсюда два следствия:
сессия переживает перезапуск сервиса сама, а смена ключа записи
(`Record.RefreshTokenKey()`) обесценивает все её выданные сессии разом.
**Куки библиотека не читает вовсе** — сессию берёт только заголовком
`Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
**Учётную запись обмен ищет двумя способами подряд, а связь с провайдером
уникальна.** Сперва — по неизменяемому признаку провайдера, а не найдя — по
адресу почты (`apis/record_auth_with_oauth2.go`, ветка `case authUser.Email !=
""``FindAuthRecordByEmail`). Найденной записи он пытается добавить связь, а на
ней стоит уникальный индекс
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)`. Отсюда
исход, обратный ожидаемому: два разных признака провайдера с **одной** почтой не
сливаются в одного владельца молча — второй вход отвергается, обмен отдаёт `400`,
сервис — `401` со строкой `Failed to exchange provider code`, а настоящая причина
остаётся в журнале хранилища строкой `failed to save linked rel: … Value must be
unique`. Дописано 2026-08-15 задачей про заглушку OIDC; получено прогоном против
временного каталога — чтение исходников давало ту же цепочку, но противоположную
развязку.
**Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом
роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию
`MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять
суток; проект умолчание не переопределяет.
## Что отвергнуто и почему
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
панель тогда не даёт по файлам ничего, и встроенные копии их не накрывают.
Довод, ради которого перевод затевался, пропадает целиком.
- **Оставить вход у приложения, а PocketBase взять только хранилищем.**
Отвергнуто: пользователей панель в этом случае не показывает вовсе, и одна из
трёх частей вопроса остаётся без ответа навсегда, а не до какой-то задачи.
- **Отказаться от перевода.** Отвергнуто человеком 2026-08-11 при выборе из трёх
способов:
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
копирование, которых у сервиса-архива нет никаких.
## Разграничение по владельцу: что выяснилось при реализации
Дописано 2026-08-14 задачей `record-ownership`. Все находки ниже получены одним
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
против настоящего хранилища на временном каталоге — в ходе ревью того change.
**Связь с выключенным каскадом не удерживает целостность при удалении.**
`core/record_model.go`, `deleteRefRecords`: при `CascadeDelete = false` и
необязательном поле хранилище **вынимает** идентификатор из поля связи и
сохраняет запись через `SaveNoValidate`. То есть «не уносить записи следом» и
«сохранить у них владельца» — разные вещи, и связь даёт только первое.
**Наружу проходит только ошибка роутера.** `apis/record_crud.go` заворачивает
отказ хука в `firstApiError(err, e.BadRequestError("Failed to delete record. Make
sure that the record is not part of a required relation reference.", err))`, а
`firstApiError` берёт первый аргумент, только если он `*router.ApiError`. Обычная
ошибка из хука до ответа не доезжает вовсе, и спрашивающий получает библиотечную
подсказку про обязательную связь — в нашем случае указывающую не на ту связь.
**`apis/file.go` выдаёт токен файла на предъявителя, а не на файл.** О файле при
выдаче он не спрашивает. Владельца судит переход по ссылке: правило просмотра
коллекции проверяет защищённое поле файла по учётной записи **из токена**. Значит
чужой токен получить можно всегда, а скачать по нему чужой файл — нет.
**Проверка сессии с именем коллекции отвечает `403`, а не `401`.**
`apis.RequireAuth("users")` пускает только запись названной коллекции; предъявитель
из другой — например, владелец панели — узнан, но не годится, и код отказа это
различает.
**Связь в SQLite лежит пустой строкой, а не `NULL`.** `RelationField.ColumnType`
даёт `TEXT DEFAULT '' NOT NULL`; сырой запрос и чтение через запись коллекции
совпадают побайтово. «Умолчания у колонки нет» верно по замыслу — пустое значение
не совпадает ни с кем, — но не буквально на уровне схемы.
+140
View File
@@ -0,0 +1,140 @@
# Фреймворк приложения: Svelte, Vue и React на одном экране
Отвечает на вопрос разведки `spa-framework-choice`: какой фреймворк берём под
приложение на четыре экрана, которое собирается в статику, вшивается в бинарник
через `go:embed` и ставится на телефон.
Кандидатов назвал владелец: Svelte, Vue и React, все с Vite. Мера тоже названа им
— размер собранной статики, простота вшивания и цена шага сборки в гейте, а не
популярность. Серверную отрисовку и надстройки над фреймворками — SvelteKit,
Nuxt, Next — не рассматривали: конвенция
[../conventions/web-ui.md](../conventions/web-ui.md) уже требует статику в
бинарнике, а все три надстройки по умолчанию ждут процесс Node рядом.
## Как снималось
Дата замеров — 2026-08-11. Всё считал в каталоге вне репозитория, который удалён
вместе с песочницей. Node 24.18.0, npm 11.16.0, Vite 8.2.1, TypeScript 6.0.3.
- **Каркасы** — `npm create vite@latest <имя> -- --template svelte-ts|vue-ts|react-ts`.
Из каждого удалил демонстрационные картинки и компонент-счётчик, чтобы в сборку
попал только пробный экран.
- **Пробный экран** один и тот же по смыслу: список записей, опрос состояния
незавершённых раз в две секунды, полоса ошибки, пустое состояние, разбор даты.
60 строк на Vue, 64 на Svelte, 69 на React; стили — один и тот же файл на 15
правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает
одинаковость экрана.
- **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними:
столько экранов предполагала задача `spa-skeleton`, сделанная 2026-08-15.
Роутеры
`svelte-spa-router` 5.1.1, `vue-router` 5.2.0 и 4.6.4, `react-router` 8.3.0.
- **Размеры** — `stat -c%s` и `gzip -9c | wc -c` по файлам `dist/`. Числа Vite в
своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят
мои.
- **Установка** — `npm ci --cache <свой пустой каталог>`; у каждого каркаса кэш
свой, иначе первый прогон скачивает общие пакеты за остальных.
- **Сборка** — `npm run build` трижды подряд с удалением `dist` и кэша Vite,
в таблице лучшее из трёх. Числа сняты на машине разработчика, не в гейте.
- **Совместимость `svelte-spa-router` со Svelte 5** взята из его описания в
реестре, а не проверена: `peerDependencies` объявляет `svelte: ^5.0.0`.
## Числа
| Мера | Svelte 5.56.8 | Vue 3.5.41 | React 19.2.8 |
| --- | --- | --- | --- |
| Пробный экран, скрипт | 35 598 Б / 13 931 Б gzip | 62 493 / 24 372 | 191 797 / 59 679 |
| Четыре маршрута с роутером | 45 053 / **17 314** | 86 890 / **33 326** | 228 750 / **72 402** |
| Стили, у всех один файл | 883 / 474 | 883 / 474 | 883 / 474 |
| Файлов в `dist/` | 3 плюс значок | то же | то же |
| Пакетов в установке, каркас | 49 | 48 | 27 |
| Пакетов с роутером | 51 | 84 (роутер 5) / 50 (роутер 4) | 29 |
| `node_modules` с роутером | 74 МБ | 92 МБ | 91 МБ |
| Установка с пустым кэшем | 7,4 с | 9,1 с | 9,4 с |
| `npm ci` с тёплым кэшем | 0,44 с | 0,41 с | 0,39 с |
| Сборка и проверка типов | 0,32 с плюс 1,16 с | 1,08 с | 0,81 с |
Пакеты считал так: имена первого уровня в `node_modules` плюс имена второго
уровня внутри областей `@…`. В `package-lock.json` записей больше — 74, 72 и 69
у каркасов, — потому что он перечисляет двоичные сборки Rollup и oxlint под все
платформы, а ставится одна.
Проверка типов у Vue и React входит в `npm run build` (`vue-tsc -b && vite build`
и `tsc -b && vite build`), у Svelte вынесена в отдельную команду `npm run check`
и в сборке не участвует — отсюда две цифры в последней строке.
## Что оказалось одинаковым и потому ничего не выбирает
- **Вшивание в бинарник.** У всех троих `dist/` — это `index.html`, один файл
скрипта и один файл стилей с хешем в имени плюс значок. Ни один не кладёт
файлов, начинающихся с точки или подчёркивания, поэтому `go:embed` берёт
каталог обычной строкой, без `all:`.
- **Node в гейте и в образе.** Шаг сборки статики нужен всем троим одинаково: на
машине разработчика, в `task gate` и слоем сборки в `Dockerfile`.
- **Установка на телефон.** `vite-plugin-pwa` 1.3.0 от фреймворка не зависит: в
его `peerDependencies` стоит Vite, и ни одного фреймворка там нет.
- **Цена шага сборки.** Секунда с небольшим у всех троих, и на фоне сборки Go и
`golangci-lint` в гейте это не различие.
Различает единственное — **размер того, что скачивает телефон**, и он расходится
вчетверо.
## Кандидаты
### Svelte
Компилятор, а не библиотека времени выполнения: в собранный файл попадает почти
только свой код, отсюда 17 314 Б на четыре экрана — вчетверо меньше React.
Реактивность и хранилище состояния встроены, третьей библиотеки под них не нужно.
Против: своего роутера у Svelte нет, а `svelte-spa-router` держит один человек.
Проверка типов идёт отдельной командой, то есть в гейте это второй шаг.
### Vue
Библиотека с официальным роутером и официальным хранилищем состояния. 33 326 Б на
четыре экрана — вдвое легче React и вдвое тяжелее Svelte. Разметка отделена от
кода однофайловым компонентом, документация переведена на русский.
Пятая версия роутера тянет в установку 34 пакета сверх четвёртой (84 против 50):
в неё встроена сборочная надстройка под маршруты по файлам. На собранный файл это
не влияет — 33 326 Б против 33 848 Б у четвёртой версии, то есть пятая даже чуть
легче, — и **надстройка не обязательна**: замер шёл на своей таблице маршрутов
через `createRouter`, ни один плагин Vite для этого не регистрировался.
Пятая версия — стабильная, а не предварительная: 5.0.0 вышла 29 января 2026,
текущая 5.2.0 — 15 июля, метка `latest` стоит на ней.
### React
Экосистема больше, чем у двух других, — числом я её не мерил, — а пакетов в
установке меньше всех: 27. Всё остальное против: 72 402 Б на четыре экрана, и ниже этого пола он не опускается, потому что
пол задаёт сама библиотека. Роутер, хранилище состояния и работа с запросами —
третьими библиотеками, каждая со своим сроком жизни.
## Что решено и от чего отказались
Решение — **Vue с роутером пятой версии**, записано в
[ADR-2026-08-11-spa-on-vue](../adr/ADR-2026-08-11-spa-on-vue.md).
Отвергнуты:
- **Svelte** — легче Vue вдвое, но своего роутера не имеет, а тот, что есть,
держит один человек. Владелец выбрал экосистему, которая переживёт проект, а не
минимальный размер: 33 КБ на телефоне не отличаются от 17 КБ на глаз, а
брошенная зависимость отличается;
- **React** — вчетверо тяжелее Svelte и вдвое тяжелее Vue, а взамен даёт
экосистему, которой приложению на четыре экрана не на что потратиться: чужих
компонентов оно не берёт, весь показ данных — список, форма загрузки и текст.
## Чего разведка не узнала
- **Как числа изменятся на настоящих экранах.** Мерил один экран и три заглушки;
загрузка файла с полосой хода, форма настроек и таблица расхода вырастут у всех
трёх. Переносится отношение, а не абсолютные значения.
- **Цену готовых наборов компонентов.** Не мерил вовсе, а именно она способна
удвоить собранный файл.
- **Во что обходится шаг сборки в образе.** Слой Node в `Dockerfile` не
собирался: время сборки образа и его вес после добавления слоя неизвестны.
Замер сделает `spa-skeleton`, которая этот слой и пишет.
- **Сколько живёт сборочная надстройка роутера пятой версии.** 34 пакета в
установке — число, а не суждение о том, как часто они ломаются.
+127
View File
@@ -0,0 +1,127 @@
# Хранилище: PocketBase против голого SQLite с каталогом файлов
Отвечает на вопрос владельца от 2026-08-22: не окажется ли голый SQLite с
каталогом аудиозаписей гибче встроенной PocketBase. Записи в каталоге задач у
вопроса нет — он поднят по ходу работы, и разведка идёт от него, а не от
постановки.
Мерилось **дерево репозитория**, а не внешний сервис: вопрос о том, что
библиотека уже держит в этом коде и чем за это плачено. Замеров на живом потоке
нет.
## Как снималось
Дата — 2026-08-22, коммит `a8fb479`, дерево чисто. Всё считано в самом
репозитории, боевые данные не участвовали.
- **Пакеты в сборке:** `go list -deps ./... | wc -l`**584**; из них с
`pocketbase` в пути — `go list -deps ./... | grep -c pocketbase`**40**.
- **Кто тянет тяжёлый модуль:** `go mod why -m <модуль>` по семи модулям.
- **Объём кода:** `find <каталог> -name '*.go' [! -name '*_test.go'] | xargs wc -l`.
- **Протечка библиотеки за адаптер:** `grep -l "pocketbase/" internal/controller/http/*.go`.
- **Узлы для варианта с монтированием** — чтением кэша модулей:
`router.Router.BuildMux()` отдаёт `http.Handler`
(`tools/router/router.go:61`), а `apis.Serve` присваивает `e.Server.Handler`
(`apis/serve.go:223`). Прототипа на этих двух узлах не собирал — проверено
только их существование.
## PocketBase здесь — фреймворк приложения, а не хранилище
Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
шесть ролей сразу, и только две из них про хранение.
| Роль | Где | Чем заменяется |
| --- | --- | --- |
| SQLite без CGO, пул записи одним соединением | `pbrepo.New` | `modernc.org/sqlite` напрямую, свои WAL, `busy_timeout` и единственный писатель |
| Схема и шаги миграций | `internal/adapter/repo/pocketbase/migrations`, 1039 строк | свой раннер либо `goose`, который тут уже был |
| Файлы записей на диске и отдача `/api/files/…` по токену | `file_repo.go`, `FileTokenPath` | каталог и свой обработчик отдачи |
| Маршрутизатор, цепочка слоёв с приоритетами, ограничитель частоты | `internal/controller/http` целиком | `net/http` и счётчик по ключу |
| Учётная запись значением `e.Auth` | `identity.go` | строка своей таблицы |
| Панель `/_/` | покупалась ради неё | ничем |
**Библиотека вышла за адаптер.** Из пяти не-тестовых файлов
`internal/controller/http` её импортируют **все пять**, из шести тестовых —
**все шесть**: 1497 строк кода контроллера и 3075 строк его проверок написаны
на `*core.RequestEvent`. Адаптер хранилища — ещё 1787 строк без шагов схемы.
## Три довода прежнего решения: один умер 2026-08-22
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](../adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
покупал библиотеку за три вещи разом, и отвергал половинчатые пути тем, что
порознь ни одна перевода не оправдывает.
1. **Правка записей в панели** — работает. Единственная роль, которой сегодня
нет замены.
2. **Файлы видны в панели, и копия накрывает их вместе с базой** — работает; тот
же ADR оговаривает, что копии сервер делает своими средствами.
3. **Вход через её провайдер OIDC****отпал**. Цитата, которой ADR отверг
вариант «взять библиотеку только хранилищем»:
> Панель показывает свою коллекцию пользователей и ничего больше. Отсюда
> следствие для целевого входа: **пользователи Authelia в панели не появятся,
> если вход делает само приложение**.
Вход делает само приложение с 2026-08-22
([ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md)):
пришедшего называет заголовок прокси, а учётную запись заводит наш
`EnsureUser`. Пользователи в коллекции есть **потому, что их пишет наш код**,
а не провайдер библиотеки. Довод, которым отвергнут отвергнутый вариант,
перестал быть верным — и вместе с ним отпало основание держать вход в
библиотеке.
## Что оплачено и не работает
- **Захват записи идёт сырым запросом мимо записей коллекции**
(`record_repo.go`, `FindAndAcquire`): `app.DB().NewQuery` с `UPDATE … RETURNING`.
На самом горячем месте абстракция не помогает, а её ограничения действуют —
хуки коллекции не срабатывают, время изменения проставляет наш запрос, времена
сравниваются строками побайтово.
- **Налог соседства двух периметров в одном процессе и на одном порту.**
Появился секрет, которого не было, — пароль суперпользователя. Панель
опубликована в интернет, и её барьер обходится подменой знака: `/%5f/`
попадает в ту же группу, что `/_/`, а правило прокси написано на литерал
([../security.md](../security.md)); дефект не закрыт. Узнавание нельзя
повесить на всю поверхность хранилища, иначе узнанный перепишет себе
`provider_login` и заберёт чужой архив — область слоя сужена, и причина стоит
абзацем в `identity.go`.
- **Пространство `/api/` нельзя закрыть на прокси**, потому что за файлами
ходит туда браузер пользователя. Своя отдача файла снимает это ограничение:
закрыть можно всё пространство хранилища разом.
## Чем платит уход
- **Панель теряется целиком.** Правка записи, возврат остановленной в работу,
просмотр очереди фильтром, прослушивание файла — всё это сегодня живёт только
там.
- **Пишем сами** шаги схемы, отдачу файла, ограничитель частоты и настройку
базы. Последняя — не формальность: у `modernc.org/sqlite` запись идёт
единственным соединением, и библиотека держит это за нас двумя пулами.
- **Раскладка каталога данных** названа необратимой в `../../CLAUDE.md`.
На стройке цена нулевая: на сервере пусто, переносить нечего.
## Что уходит из сборки, а что остаётся
`go mod why -m` показал, что шесть модулей достижимы **только** через
PocketBase: `disintegration/imaging` (через `tools/filesystem`),
`domodwyer/mailyak/v3` и `golang-jwt/jwt/v5` (через `core`),
`ganigeorgiev/fexpr` (через `apis`), `spf13/cobra` (набор команд),
`go-sql-driver/mysql` (через `pocketbase/dbx`). `modernc.org/sqlite` остаётся:
он нужен и без библиотеки, и требование обходиться без CGO с ним сохраняется.
## Варианты и что выбрано
| Вариант | Что делает | Чем платит |
| --- | --- | --- |
| Оставить как есть | ноль работы | долг растёт с каждой задачей на `RequestEvent` |
| **Уйти целиком** | SQLite напрямую, каталог файлов, свои шаги схемы и маршруты | панель теряется сразу, работа одной порцией |
| Уйти в два шага | сначала снять с периметра HTTP, панель оставить; потом с хранилища | панель жива в промежутке, работа та же, но двумя порциями |
| Локализовать протечку | библиотека не выходит за `internal/adapter/repo` | решение откладывается, панель остаётся |
**Решением владельца от 2026-08-22 взят уход целиком, и панель не заменяется
ничем**: пока стройка не кончилась, остановленную запись возвращают в работу
запросом к базе. Решение записано в
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Обстоятельство, которое решило дело: на сервере данных нет и сервис остановлен,
поэтому смена стоит только кода. Дешевле она не станет никогда — каталог
`internal/controller/http` прирастает кодом на чужих типах с каждой задачей.
+55
View File
@@ -0,0 +1,55 @@
# Разбор TOML: какое семейство отказов несёт значения из файла
Отвечает на вопрос, возникший по ходу задачи `telegram-enabled-flag`: можно ли
пересказывать отказ библиотеки разбора в журнал, если в файле настроек лежат
секреты. Наблюдение понадобилось потому, что ревью дизайна назвало этот путь
утечкой, а чинить его без разреза пришлось бы выбрасыванием всего текста отказа —
то есть платой разборчивостью на каждой опечатке.
## Как снималось
Не замером, а **чтением исходников** зависимости, зафиксированной в `go.mod`:
`github.com/BurntSushi/toml` версии **v1.5.0**. Смотрел `error.go`, `parse.go`,
`decode.go`, `meta.go`, `lex.go` в кэше модулей. Дополнительно прогонял
`toml.Decode` на правдоподобных опечатках — в каталоге вне репозитория, чтобы не
править код проекта.
## Что выяснилось
- **Значения из файла несёт ровно одно семейство отказов — `toml.ParseError`.**
Его поле `Message` собирается из разбираемого куска: `Invalid float value: %q`
(`parse.go:341`), `invalid duration: %q`, `%v is out of range`, `Invalid
integer %q`. Туда же лексер отдаёт свои отказы через `panicItemf`
(`parse.go:134`).
- **Прочие отказы декодера значений не содержат вовсе.** Их строит `md.e`
(`decode.go:577`) и `md.badtype` — из имён ключей, имён типов (`%T` через
`fmtType`) и длин. Обойдены все места: `decode.go:282,288,297,329,348,385,388,399,428,437,467,487,518,552,561`.
- **`LastKey` секрета нести не может.** Текущий ключ присваивается только после
`itemKeyEnd`, то есть после `=` (`parse.go:200`), а лексер ключа до `=` не
доходит (`lex.go:481-501`). Посторонняя строка со значением ключом не станет.
- **Поле `Line` у `ParseError` врёт, а `Position.Line` — нет.** `panicErr` и
`panicItemf` кладут в устаревшее поле `Line` значение `it.pos.Len`, то есть
**длину**, а не номер строки (`parse.go:97,106`). Брать надо `Position.Line`.
- **`ParseError` возвращается значением, не указателем** (`decode.go:564`,
`parse.go` целиком), поэтому `errors.As` берёт целью `toml.ParseError`, а не
`*toml.ParseError`. `Unwrap` у типа нет.
- **Ветка без последнего ключа достижима обычной опечаткой.** Незакрытая скобка
секции даёт `LastKey=""`:
```
вход "[telegram\nenabled = true\n"
→ LastKey="" err=toml: line 2: expected '.' or ']' to end table name, but got '\n' instead
```
## Что из этого следует для кода
Разрез по семейству отказа: `ParseError` пересобирается своими словами — путь,
строка, столбец, последний ключ, — а его `Message` не берётся; прочие отказы
проходят как есть. Так инвариант «Секрет не покидает конфиг» держится, а
несовпадение типов по-прежнему называет ключ и типы.
**Наблюдение привязано к версии.** Версия, переложившая значение в другое
семейство или сменившая возврат на указатель, вернёт утечку молча. Держат это
проверки поломанного файла настроек в `internal/config/config_test.go`; при
подъёме версии библиотеки их отказ читается как сигнал перечитать эту записку, а
не как случайный шум.
+67
View File
@@ -0,0 +1,67 @@
# Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина
Отвечает на вопрос, возникший по ходу задачи `config-test-headers-login`: что
делает декодер настроек с ключом и секцией, которых структура не знает, и виден
ли этот случай хоть чем-нибудь. Наблюдение понадобилось потому, что ревью нашло
опечатку в имени новой секции `[auth.test_headers]`, проходящую молча, и без
разреза нельзя было сказать, где кончается предмет задачи и начинается свойство
самой библиотеки.
Соседняя записка о той же библиотеке — [toml-decode-errors.md](toml-decode-errors.md)
— разбирает семейства **отказов**; здесь предмет обратный: случай, отказа не
дающий.
## Как снималось
Прогонами на зависимости, зафиксированной в `go.mod`:
`github.com/BurntSushi/toml` версии **v1.5.0**. Оба уровня снял триаж ревью
2026-08-23, отчёт —
[triage-2026-08-23.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md),
находка 2 и факт, подтверждённый разбором прохода `operations`. Временные файлы
прогонов удалены, бинарник поднимался в каталог вне репозитория, не в `data/`.
- **Модульный.** Вход
`[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"`
опечатка в имени секции.
- **Сквозной.** Настоящий бинарник на конфиге с той же опечаткой, порт 18099,
каталог данных вне репозитория; проба `curl /app/me`.
## Что выяснилось
- **Незнакомая секция и незнакомый ключ отказа не дают: `decode err=<nil>`.**
Разбор проходит целиком, поля структуры остаются нулевыми, и отличить «в файле
этого нет» от «в файле это написано с опечаткой» по результату разбора нельзя.
В прогоне: `Server.Debug=true len(TestHeaders)=0`.
- **Потерянное называет только `MetaData.Undecoded()`.** Он возвращает перечень
путей, которых структура не знала: `[auth.test_headrs
auth.test_headrs.Remote-User]`. Значение это в проекте не читает никто — ни
загрузка настроек, ни проверки старта.
- **Контроль показывает, что дело в уровне, а не в разборе вообще.** Ту же
опечатку **внутри** известной секции (`Remote-Usr` вместо `Remote-User`)
ловит проверка старта — `auth: секция [auth.test_headers] называет
заголовок, которого сервис не читает: Remote-Usr`, — потому что судит её код
проекта, а не библиотека. Ошибка в имени самой секции до этого кода не
доходит.
- **Сквозной прогон следа не оставляет вовсе.** Бинарник поднимается без
предупреждения, `curl /app/me` отвечает `401`, а в журнале стоит только
`INFO "Incoming request" … http.status_code=401`.
- **Отсюда направление отката бинарника безопасно.** Прежний образ, получивший
конфиг с ключами, которых его структура ещё не знает, эти ключи игнорирует и
поднимается. Свойство держится ровно на тишине выше: перечень
`MetaData.Undecoded()` никто не судит.
## Что из этого следует для кода
Свойство сегодня используется, а не терпится: правило выкладки «конфиг после
образа» опирается именно на него, и его дом — [../architecture.md](../architecture.md),
«Эксплуатация». Здесь записано, чем свойство обеспечено и как проверено, а не
надо ли его менять.
Отсюда же цена любой будущей проверки `MetaData.Undecoded()`: непонятый ключ,
роняющий старт, закрывает опечатки во всех секциях разом — и тем же движением
снимает безопасность отката, потому что прежний образ перестанет поднимать
конфиг новее себя. Разменивать одно на другое — отдельное решение владельца,
а не попутная правка.
**Наблюдение привязано к версии.** Версия, начавшая судить незнакомые ключи
сама, сменит оба следствия разом — молчаливую опечатку и безопасный откат.
+69
View File
@@ -0,0 +1,69 @@
# Раздача приложения: что делают за нас библиотека и сборщик
Отвечает на вопросы, возникшие по ходу задачи `spa-skeleton`, — какие свойства
раздачи приходят не из нашего кода, а из стандартной библиотеки, из PocketBase и
из инструментов приложения. Наблюдения понадобились потому, что ревью нашло три
места, где записанное намерение расходилось с тем, что на деле делает чужой код.
## Как снималось
Прогонами на живом бинарнике (свой конфиг с выдуманными ключами, свой каталог
данных вне репозитория) и чтением исходников зависимостей, зафиксированных в
`go.mod`: `github.com/pocketbase/pocketbase` версии **v0.39.10** и стандартной
библиотеки Go. Отдельно — прогоны установщика и сборщика приложения в контейнере.
Числа ниже сняты 2026-08-15 на этом прогоне, а не взяты из чужих записок.
## Что выяснилось
- **Маршрутизатор стандартной библиотеки сравнивает сегменты пути после
раскодирования.** Поэтому `/%5f/` попадает туда же, куда `/_/`, а `/%68ealth`
— туда же, куда `/health`: ответы совпадают байт в байт. Исходная форма
остаётся в `URL.RawPath`, и решение, принимаемое **вне** сервиса по сырому пути
— правилом обратного прокси, — такой формы не видит. Цена записана в
[security.md](../security.md), «Периметр»: барьер перед панелью владельца
обходится подменой одного знака.
- **PocketBase пишет каждый запрос в свою таблицу журнала**, а не только в вывод
контейнера: слой `activityLogger` подключён ко всем маршрутам и кладёт путь
целиком
(до 3000 знаков), адрес отправителя, источник перехода и клиент. Умолчания —
хранить пять суток, адрес записывать. Готовая раздача статики
(`apis.Static`) первой же строкой ставит признак «успех не записывать»; своя
раздача этого признака не наследует, и его надо ставить руками. Отсюда правило
в [review.md](../review.md): журналов **два**, и говорить надо про оба.
- **Вшитая файловая система не несёт времени правки.** `embed.FS` отдаёт нулевое
время у любого файла, поэтому отдача файла стандартной библиотекой никогда не
отвечает подтверждением «не менялось» — всякая проверка приходит полным телом.
Заголовок, обещающий дешёвую проверку, без метки ответа обещает то, чего код не
делает.
- **Сборщик приложения чистит выходной каталог перед каждой сборкой.** Метка,
положенная рядом с собранным ради того, чтобы каталог существовал в git,
уезжает первым же прогоном. Живёт она только этажом выше выходного каталога.
- **Проверка типов однофайловых компонентов не работает с седьмой линией
TypeScript.** `vue-tsc` версии 3.3.10 зовёт у компилятора точку входа, которой
новый компилятор не отдаёт, и сборка падает на этапе проверки типов. Рабочая
пара — пятая линия TypeScript.
- **Установщик пакетов без сети не отказывает, а виснет.** Он уходит в повторы с
нарастающей паузой **на каждом пакете**, и набор проверок вместо кода отказа
просто стоит. Пределы у отдельных обращений положения не спасают: их сумма и
даёт зависание. Помогает короткое обращение-проба перед установкой.
- **Вес приложения в бинарнике равен весу собранного.** Замер: две сборки, с
собранным приложением и с пустым каталогом, разница — 86 072 байта, то есть
ровно `index.html` плюс единственный ресурс. Собранное приложение на четыре
экрана в разведке `spa-framework` весило того же порядка.
## Чего эта записка не узнала
- **Во что ступень сборки обходится образу по времени.** Прогон до конца не
доходит: из контейнеров этой машины нет исходящей сети при рабочем разрешении
имён. По весу вопрос закрыт иначе — ступень в рабочий слой не копируется, и
финальный образ от неё не растёт вовсе.
- **Как поведёт себя раздача под настоящим потоком.** Ограничителя частоты на
корневом маршруте нет, а профиля нагрузки у проекта нет тоже.
- **Что делает настоящий браузер** с этими заголовками: проверено кодами ответов
и заголовками, а не браузером.
+925
View File
@@ -0,0 +1,925 @@
# Ревью: настройка и журнал
## Как настроен конвейер
Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/`; имя файла
менялось по ходу — `triage.md`, `report.md`, `design-review.md`,
`code-review.md`, `triage-<дата>.md`. Самый ранний — `fix-http-handler-tests`
2026-08-11; самый поздний здесь не называется: строка протухала бы с каждым
прогоном, и смотреть его надо в самом архиве.
Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта
в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже.
Разделы ниже заведены наперёд по коду 2026-08-11 и с тех пор правятся урожаем
прогонов.
**Проход, поднявший сервис, обязан его остановить.** Живой прогон стал доступен
2026-08-13 (см. «Недоступно проверке»), и первый же им воспользовался: враждебный
проход поднял сервис на своём порту и оставил работать. Следующий прогон занять
порт не смог, а его запросы молча ушли к чужому процессу — то есть замеры
относились к прежней сборке, и по ним едва не был объявлен исход. Отсюда два
правила, оба прозой и без механизации: **поднял — останови за собой**, а
**меряющий убеждается, что отвечает его собственная сборка** (порт занят им,
новое поведение видно в выводе). Признак дешёвый: если ожидаемого нового поля,
метрики или строки нет вовсе — вероятнее всего, отвечает не твой процесс.
**Ни один проход не сообщает свой потолок, и это надо читать как границу
покрытия.** Прогон `telegram-enabled-flag` 2026-08-13: у прохода есть потолок
находок, и устав велит объявлять строкой, сколько осталось за срезом и какого
рода. Ни один из четырёх проходов такой строки не дал, и заметил это только
триаж. Пока так, «находок больше нет» в отчёте прохода неотличимо от «больше не
поместилось». Выше прочих риск у прохода, вбирающего темы разом: у него одна
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
остаётся сверка триажа.
Пробел повторился на прогоне `config-test-headers-login` 2026-08-23, и это уже
не единичный случай: строки о потолке не дал ни один из шести проходов, а
заметил это снова только триаж. Состав прогона при этом был самым широким из
тогда доступных, — и разница с прошлым разом ровно в числе проходов,
промолчавших одинаково. Читать пробел надо как границу покрытия каждого прогона,
а не как свойство одного из них.
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
узлах».
### Типовые узлы
Рода узлов проекта и проверяемые свойства к каждому.
**Шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`):
- отличает «задач нет» от отказа и не считает первое ошибкой;
- при отказе на середине оставляет задачу в состоянии, из которого повтор
корректен, либо переводит в `failed` осознанно;
- не теряет ссылку на файл: `job.FileID` переставляется только после того, как
запись о новом файле создана;
- повтор шага на той же задаче не создаёт лишних файлов и записей;
- отвечает пользователю ровно один раз.
**Транспорт** (`internal/controller/http`):
- проверяет право отправителя до всякой работы;
- не логирует ошибку, которую уже залогировал доменный слой;
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
- закрывает то, что открыл, на всех ветках выхода.
**Раздача собранного приложения и шаг его сборки** (`controller/http/webapp.go`,
шаг `front`):
- путь, принадлежащий корню сервиса, разметку не отдаёт никогда, а перечень
корней порождает регистрацию маршрутов, а не описывает её;
- несовпавший ресурс под каталогом сборщика отвечает `404`, а не разметкой с
кодом `200`;
- раздача ставит долгий неотзываемый срок хранения **только** файлу из каталога
сборщика: отозвать его у браузера сервису нечем;
- отсутствие сборки громкое — код ответа, страница и строка журнала; «сборки
нет» отличается от «файла нет»;
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
**отказывает, а не висит**;
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала.
Журнал у сервиса с 2026-08-22 **один** — свой, в вывод контейнера: второй
ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с
адресом отправителя. Правило при этом расширилось, а не сузилось: путь не
пишется дословно ни под каким корнем, включая корень приложения.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
- различает «сервис ответил отказом» и «сервис недоступен»;
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
успех молча.
**Репозиторий хранилища** (`internal/adapter/repo/sqlite`; шаги схемы —
подпакетом `migrations`):
- список колонок совпадает во всех трёх местах — `writeOwnedByPipeline` вместе с
`writeRecord`, `readRecordColumns` и `rowToAudioRecord` — и в шаге схемы
(инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»). Колонки называются
**именами**: именованный параметр запроса и место назначения по имени, а не
позиция в списке;
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата, и держатель узнаётся значением признака;
- репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его
из единой точки ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему
целиком.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`):
- отсутствие программы в `PATH` отличается от отказа обработки;
- вход, пришедший от пользователя, не попадает в аргументы командной строки
неразобранным;
- пустой или частично записанный выходной файл считается отказом;
- процесс не висит вечно.
**Любой узел** — сверх свойств своего рода:
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
становится неотличим от привычного шума (журнал, запись 2026-08-10).
Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни
мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его
границы — [CLAUDE.md](../CLAUDE.md), «Запреты».
### Типовые ложноположительные
- **«Хранилище молча сливает две учётные записи с одной почтой в одного
владельца».** Для версии v0.39.10 неверно, и неверна именно развязка. Первая
половина цепочки настоящая: обмен ищет запись по признаку провайдера, а не
найдя — по адресу почты, и приходит к чужой записи. Но повесить на неё второй
признак он не может — уникальный индекс
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)` связь
отвергает, обмен отдаёт `400`, а сервис — `401` со строкой
`Failed to exchange provider code`. Отказ **громкий**, тихого слияния владельцев
не происходит, и ложно-зелёной проверки разграничения такой дефект не даёт.
Проверено прогоном 2026-08-15 (задача про заглушку OIDC); найдено чтением
исходников библиотеки, опровергнуто запуском — то есть цена гипотезы, добытой
без прогона, здесь и измерена.
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
логирует его и не считает в метрику. Норма записана требованием
[pipeline](../openspec/specs/pipeline/spec.md).
**Оговорка, и она тут главная:** ложноположительным считается только само
молчание воркера. Проверка **формы** узнавания ложноположительной не является:
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
её как известную нельзя.
- **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят.~~
**Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул
одинаковых воркеров конкурирует за один и тот же набор записей, и второй
воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о
гонке захвата стала настоящей и выбрасывается только по существу — механика
захвата и её слабые места в [database.md](database.md), «Представление
данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю
редакцию, иначе выбросил бы настоящую находку как известную.
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост.
- **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
иначе выбросил бы настоящую находку как известную.
До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
дефект и не новость»: разграничения не было сознательно. Первая отмена
2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
владельца, принятую ботом.
Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
колонка владельца пустого значения больше не принимает, ничьих записей у
сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
а не известное исключение.
### Вопросы по темам
Форма: `<тема>: <вопрос> (<откуда>)`.
- `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть
в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
`cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте встал
`cmd/devtools proxy`, а 2026-08-23 задачей `config-test-headers-login` убран и
он: заголовки входа подставляет сам сервис под предохранителем
`[server] debug`. Проверки ставят заголовок сами и подставного собеседника не
держат вовсе. Тем же вопросом судится подставной распознаватель. **Пробел
закрыт той же задачей:** норма о подставных собеседниках записана в
[architecture.md](architecture.md), «Принципы» — пункт «Подставной собеседник
в боевом бинарнике объявлен своим ключом»; здесь она не пересказывается.
Вопрос при этом остаётся вопросом:
норма называет, где собеседнику жить, а не сколько домов у него уже завелось
(ревью задачи про заглушку OIDC, 2026-08-15).
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
внешнего собеседника и это держат правила `noctx` и `contextcheck`
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
собеседник»), а исход прерванного шага нормой по-прежнему не описан
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
дефектом «остановка хоронила запись»).
- `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
`speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10).
- `security`: не попал ли в лог текст расшифровки, имя файла пользователя или
URL с токеном бота (запрет в [security.md](security.md) и
[conventions/logging.md](conventions/logging.md)).
- `security`: не строится ли путь на диске или ключ объекта из значения,
пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
(чтение `service/transcribe.go`, 2026-08-10).
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
он единственный, которым запись попадает в хранилище
([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
под маркерами долга, а соблазн дописать туда ещё — самый большой.
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
рубеж — одним дескриптором
(CLAUDE.md, «Инварианты»).
- `autotests`: судит ли проверка формы ответа по **настоящему запросу**, а не по
прямому вызову отображателя ошибки. Вызов напрямую формой ответа не является и
остаётся зелёным, когда отказ рождается слоем ниже обработчика (запись журнала
2026-08-15 про единую форму отказа).
- `operations`: есть ли у новой выборки свой индекс. Единственный индекс записи
заведён под захват воркера — по рубежу и признаку остановки, — и выборке,
сужаемой владельцем, он не помогает ничем: замер 2026-08-15 показал полное
сканирование таблицы и рост времени страницы вместе с **чужими** записями.
- `security`: не схлопнулись ли внутрипроцессные запросы в один счётчик
ограничителя частоты. Запрос, собранный руками, приходит без адреса, а
вырожденное значение библиотека отдаёт не пустой строкой, и её собственный
страж «пустой ключ пропускаем» такое значение не ловит (запись 2026-08-15).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
числа файлов здесь не называем — оно протухает с каждой задачей.
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
2026-08-12 роняет гейт правилом линтера
([conventions/go-linters.md](conventions/go-linters.md), «Механизировано»), и
спрашивать о нём не нужно.
- `security`: не открылась ли снова поверхность, которую приносит хранилище, —
собственная регистрация, вход по паролю, одноразовый код, восстановление
доступа, продление сессии. Всё это приходит включённым и закрывается нами
(задача `oidc-login` 2026-08-12).
- `security`: не появился ли второй способ получить сессию к тому же человеку —
заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
- `operations`: доходит ли отзыв доступа у провайдера до сервиса и за какой срок —
после входа сервис к провайдеру не обращается, и канал здесь один
(ADR-2026-08-12-session-without-refresh).
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
приложения, — сборка роутера хранилища оказалась именно такой.
### Когда звать глубокое ревью
Проектная конкретизация признаков, по которым зовут `av-dev:code-deep-review`.
Что за признаками следует и каким составом идёт прогон, решает сам скилл ревью;
здесь только места этого проекта.
**Смотрим целиком** (область кода, а не дифф задачи):
- **вход и разграничение доступа** — звенья `internal/controller/http`
(узнавание, требование учётной записи, ограничитель частоты, подстановка
заголовков отладочного запуска) вместе со спекой
[access](../openspec/specs/access/spec.md). Возвращаются сюда чаще, чем куда бы
то ни было ещё, и журнал дефектов ниже это показывает: закрытая поверхность, в
которую не мог войти никто; проверка, читавшая живую карту заголовков вместо
ответа; анонимный запрос, навсегда замедлявший запись; путь анонима, уехавший в
журнал; ключ бюджета ограничителя, который выбирал тот, кого ограничивают. С
2026-08-22 барьер держит заголовок от прокси, изъятие у него одно — отладочный
запуск ([security.md](security.md), «Периметр»), — а настоящей Authelia в
прогоне нет (см. «Недоступно проверке»);
- **пакет хранилища** `internal/adapter/repo/sqlite` — здесь живёт инвариант
«Колонки записи правятся в трёх местах» ([CLAUDE.md](../CLAUDE.md),
«Инварианты», major): места, цена забытого и то, чем держится сверка, названы
там. Смотрится целиком потому, что компилятор не видит ни одного из мест;
- **конвейер расшифровки** `internal/service` вместе с дескриптором рубежа
`internal/entity/stage.go` — здесь живёт инвариант «Рубеж объявляется одним
дескриптором» ([CLAUDE.md](../CLAUDE.md), «Инварианты», major), и цена
забытого рубежа названа там. Сюда же дефекты о потере уже полученного: пустой
второй ответ распознавателя, стиравший сохранённую расшифровку, и остановка
сервиса, хоронившая конвертируемую запись;
- **распознаватель** `internal/adapter/recognizer/yandex` — единственное место,
чья ошибка стоит денег ([CLAUDE.md](../CLAUDE.md), «Запреты», «Yandex Cloud за
деньги»). Живым прогоном оно не проверяется вовсе (см. «Недоступно
проверке»), и разбор остаётся единственным способом судить о нём.
**Необратимое здесь.** Перечень необратимого один и лежит в
[CLAUDE.md](../CLAUDE.md), «Работа»; здесь — только места кода, которых его
пункты касаются, и правило прохода: находка в таком месте уходит человеку
развилкой, а не чинится молча.
- применённый шаг схемы — `internal/adapter/repo/sqlite/migrations`;
- формат файла на диске и раскладка каталога данных —
`internal/adapter/repo/sqlite`, `store.go`;
- публичный контракт HTTP API — `internal/controller/http`;
- имя ключа конфига — `internal/config`;
- действие с боевыми данными и с Yandex Cloud, ротация секрета —
`internal/adapter/recognizer/yandex`.
Строка, уже ушедшая в журнал контейнера или меткой метрики, из перечня не
берётся: её необратимость записана инвариантами о секрете и о содержимом записи
([CLAUDE.md](../CLAUDE.md), «Инварианты», оба critical), и правка кода помогает
там только следующей записи.
### Недоступно проверке
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы;
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
сервиса. Ни того ни другого в прогоне нет, а с 2026-08-22 от прокси зависит
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
- `security`: поведение браузера с куками. Своих кук сервис не ставит с
2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его
панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался
как возврат недоступного проверке, а не как обычная работа.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма звали его до
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
длительность от подставного источника. Своего теста у
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
можно: он встаёт своим единственным входом на выдуманных непустых ключах
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди
предметов осмотра нет с 2026-08-22.
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
остановку; нельзя — расшифровку и заливку.
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по
секции `[auth.test_headers]` под предохранителем `[server] debug` — прежде
`cmd/devtools proxy`, убранный 2026-08-23, — и живьём проверяются узнавание,
заведение учётной записи первым обращением, отказ с недоверенного адреса и
отказ старта на пустом перечне.
Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
контуре (см. «Не проверит ни один проход»).
## Журнал дефектов
Записи новые сверху. `[пойман ревью]` — дефект нашёл прогон конвейера,
`[пойман сканером]` — тест-сканер `internal/archrules`, `[проскочил]` — дефект
уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
оракул, и выдумывать оракул задним числом нельзя.
## 2026-08-23 — путь, выбранный анонимом, уезжал в журнал под корнем приложения [пойман ревью]
- **Где:** `internal/controller/http/journal.go`, `JournalRoute`; задача
`storage-without-pocketbase`
- **Симптом:** неузнанный писал в журнал владельца свой текст произвольной длины.
Путь под корнем приложения уходил в строку дословно — в том числе при ответе
`401`, потому что слой журнала стоит снаружи ограничителя частоты
- **Причина:** правило «путь спрашивающего в журнал не идёт» было записано только
для запроса, отданного приложению. Путь вида `/app/<текст>` принадлежит
сервису, под то правило не подпадал и уезжал целиком, хотя множеством значений
под корнем распоряжается тот же аноним
- **Чем воспроизведён:** прогон враждебного прохода — путь в 1 044 480 знаков дал
прирост журнала в 1 044 632 байта; одно соединение за 1,003 с дало 122 запроса
и 121,5 МиБ журнала; 120 отказов ограничителя оставили 240 строк
- **Почему не поймали раньше:** правило записали по месту, где его впервые
понадобилось применить, а не по признаку «значением распоряжается спрашивающий».
Зазор был ровно шириной в корень приложения
- **Что меняем:** `JournalRoute` обобщает всё, что накрыто корнем приложения, а
длину отдаёт полем `http.path_length`; дословно пишутся только адреса из
закрытого перечня. Правило в [conventions/logging.md](conventions/logging.md)
переписано на все корни разом — оно стоит теперь у строки о всяком входящем
запросе, а не у строки о раздаче приложения
## 2026-08-23 — ключ бюджета ограничителя выбирал тот, кого ограничивают [пойман ревью]
- **Где:** `internal/controller/http/rate_limit.go`, `clientAddress`; задача
`storage-without-pocketbase`
- **Симптом:** ограничитель пропустил 1200 запросов одного спрашивающего при
бюджете 120 за окно. Заодно карта счётчиков росла линейно от числа выдуманных
адресов
- **Причина:** адрес брался из **левого** значения `X-Forwarded-For`, а прокси
заголовок дописывает, а не заменяет. Левым значением распоряжается сам
спрашивающий, значит он же выбирает и ключ карты — и меняет его на каждом
запросе
- **Чем воспроизведён:** прогон враждебного прохода — 1200 пропущенных запросов
при бюджете 120; 200 000 ключей в карте дали прирост кучи в 19 810 376 байт
- **Почему не поймали раньше:** слой писался заново вместе с транспортом, а
свойство «ключ бюджета не выбирает тот, кого ограничивают» не стояло ни в
конвенции, ни в типовом узле — его держала прежде чужая библиотека
- **Что меняем:** цепочка читается справа налево, доверенные адреса
отбрасываются, ключом становится первый недоверенный, а заголовок читается
всеми строками, а не одной. Требование к контуру этим снято: дописывающий
прокси правилом покрыт — [security.md](security.md), «Периметр»
## 2026-08-23 — инвариант о колонках записи потерял предмет [пойман ревью]
- **Где:** [CLAUDE.md](../CLAUDE.md), «Инварианты»;
`internal/adapter/repo/sqlite/record_mapping.go`, `internal/archrules`
- **Симптом:** инвариант называл поимённо `applyOwnedByPipeline`, `applyToRecord`
и `recordToAudioRecord` — функций с такими именами в коде уже не было. Сослаться
на инвариант как на оракул стало нельзя
- **Причина:** сторож и отображение переписаны под новую форму хранилища, а текст
инварианта остался от прежней. Мест при этом стало три: что спрошено
(`readRecordColumns`), куда лягут (`recordRow`) и что доедет до сущности
(`rowToAudioRecord`), — а сверялось правилом одно
- **Чем воспроизведён:** `grep` по трём прежним именам — пусто; `grep` по
`rowToAudioRecord` в `internal/archrules` — пусто
- **Почему не поймали раньше:** инвариант проверяется правилом, а имена в его
тексте — ничем. Текст и сторож разошлись молча
- **Что меняем:** инвариант назван действующими именами и действительным числом
мест; правило `internal/archrules` расширено на `rowToAudioRecord` — перечень
колонок чтения сверяется с перечнем присвоений в сущность
## 2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью]
- **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа;
доккоммент пакета, подсказка флага `-sub` и проза `config.example.toml`
- **Симптом:** рецепт «второй вошедший получается сменой `-sub`» записан в трёх
местах и в короткой форме не работал вовсе. Заглушка отдавала обоим `sub` одну
и ту же почту умолчанием, вход отвечал `401`, а причина оставалась строкой в
журнале хранилища
- **Причина:** обмен ищет учётную запись сперва по признаку провайдера, а не
найдя — по адресу почты. Второй `sub` при общей почте приходил к первой записи,
а признак провайдера на записи уникален — `idx_externalAuths_record_provider`
и связь отвергалась. Умолчание почты стояло своим значением вместо выведенного
из `sub`
- **Почему не поймали раньше:** рецепт проверяли **длинной** формой, где почта
задана флагом явно. Короткую не гонял никто, хотя записана она первой и берут
читатели именно её
- **Что меняем:** проверять ту форму рецепта, которая записана **короче всех**.
Оракул — прогон именно её: два входа подряд разными `-sub` без прочих флагов,
затем счёт записей в коллекции пользователей. Само умолчание почты теперь
выводится из `-sub`
## 2026-08-15 — своя раздача статики потеряла отказ от записи успеха [пойман ревью]
- **Где:** `internal/controller/http/webapp.go`, регистрация корневого маршрута;
задача `spa-skeleton`
- **Симптом:** каждый успешный ответ разметкой и ресурсом клал в журнал
хранилища выбранный анонимом путь вместе с его адресом и держал строку пять
суток. При этом строка `docs/review.md`, добавленная той же задачей,
утверждала, что путь анонима в журнал не идёт
- **Причина:** готовая раздача статики библиотеки первой же строкой ставит
признак «успех не записывать». Своя написана мимо неё — и не зря, подстановка
разметки у готовой не отличает отсутствующий ресурс от неизвестного пути, — но
признак при переписывании не перенесён.
Журналов у сервиса два, а сделанная защита закрыла один
- **Почему не поймали раньше:** свойство было записано **утверждением**, а
проверялось только против журнала контейнера. Второй журнал живёт в базе, и ни
один тест туда не смотрел
- **Что меняем:** утверждение о журнале называет оба журнала поимённо. Оракул —
чтение таблицы журнала после прогона: три успешных запроса не оставляют строк,
два отказа оставляют
## 2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью]
- **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача
`json-api-for-spa`
- **Симптом:** три отказа под корнем приложения — превышение
потолка тела, ограничитель частоты и неизвестный путь — уходили телом
библиотеки, без машиночитаемого кода и без предела числом. То есть форм отказа
на адресах приложения было две, а не одна, — ровно то, ради чего задача и
заводилась
- **Причина:** отображение доменной ошибки заведено верно, но покрывает лишь то,
что вернул **обработчик**. Предел тела и ограничитель частоты рождают отказ
слоями ниже, а «ничего не совпало» — вовсе маршрутом корневой группы, к
которому слои нашей группы не привязаны. Комментарий у слоя при этом перечислял
все три случая как закрытые
- **Почему не поймали раньше:** оракулом служил комментарий, а не прогон.
Приёмочный тест звал отображатель **напрямую** ошибкой, которую сам же и
сочинил, — запроса он не слал и потому оставался зелёным независимо от того,
что происходит при настоящем HTTP-запросе. Ветвь `too_large` при этом не имела ни одного
производителя в рабочем коде
- **Что меняем:** проверка, стерегущая форму ответа, обязана слать **настоящий
запрос**; вызов отображателя напрямую формой ответа не является. Добавлено
вопросом в раздел ниже
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
и `StructureRepository.Put`; путь до них — `poll``storeOutcome` в
`internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не
касался: она этот путь не трогала
- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не
считается — наружу уходит пустой результат без отказа. Замена содержимого шла
безусловно, и повторный опрос той же операции клал пустое поверх сохранённой
расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос
готовности отдавал `done` без текста
- **Причина:** соседний хранитель того же результата — сырой ответ провайдера —
от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст
и структура реплик такого условия не имели. Разное правило у двух хранителей
одного результата
- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, —
`expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве:
`internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`;
он же проверяет, что до второго ответа дело действительно дошло
- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но
и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа
отказало либо человек снял остановку в панели. Ни один прогон до этого не строил
такого входа, а от чтения кода защита у соседа выглядела общей
- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано
нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих
текст, больше одного, и правило у одного из них у остальных читалось бы как
снятое. Дефект существовал до той правки, чинился решением владельца от 2026-08-14 в
задаче, которая его нашла
## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]
- **Где:** `internal/service/transcribe.go`, шаг завершения; документы
`docs/conventions/logging.md` и `docs/architecture.md`
- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от
успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
- **Причина:** единственным следом этого случая был текст, уходивший отправителю
в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез
вместе с ней — при том, что конвенция журнала называет пустой текст
распознавания поимённым примером уровня «может стать проблемой», а обзор
архитектуры обещал заглушку
- **Чем воспроизведён:** `internal/service/recognition_test.go`,
`TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт
готовую операцию с пустым результатом, проверка судит уровень строки и
идентификатор записи
- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого
признака, а не сам признак; такое не видно ни компилятору, ни грепу по
удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем,
что осталось в коде
- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с
идентификатором записи; строка обзора архитектуры переписана на фактическое
поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным
потребителем сигнала** — сигнал переживает канал только там, где его переносят
руками
## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
попадает в отказ» задачи `telegram-enabled-flag`. Дефект в самой проверке, кода
сервиса он не касался
- **Симптом:** проверка была зелёной и утверждала, что отказ `TelegramConfig.Validate()`
не несёт значения ключа доступа. Приёмочный критерий задачи считался закрытым ею
- **Причина:** двойная, и каждая половина достаточна. Утверждение искало
подстроку `enabled = true при`, а в сообщении стоит `при enabled = true`
порядок слов обратный, и такой подстроки не бывает ни при каком входе. Глубже:
`Validate()` отказывает **только** на пустом ключе, то есть значения, которым
можно проговориться, на этом пути не существует вовсе. Комментарий при этом
утверждал «Ключ непуст», а в теле стояло `BotToken: ""` — описан был не тот
вход, который задан
- **Чем воспроизведён:** триаж скопировал дерево во временный каталог и заменил
тело `Validate()` на утекающее — `fmt.Errorf("... bot_token=%q ...", c.BotToken)`.
Проверка осталась зелёной
- **Почему не поймали раньше:** проверка написана в той же задаче и той же рукой,
что и код; гейт зелёный, а зелёная проверка неотличима от работающей. Поймали
два прохода независимо — разбор кода и сверка требований
- **Что меняем:** проверка переписана честно и переименована: половина требования
«сообщение не несёт значения» на этом пути **вакуумна**, и это названо прямо, а
настоящий сторож той же нормы указан по имени — он живёт там, где непустой ключ
в отказ попасть действительно может, в проверках отказа разбора файла настроек.
Класс всплывает **третий раз** (2026-08-11 «проверка приёма не могла упасть»,
2026-08-12 «проверка не могла упасть: читала живую карту заголовков»), и в этот
раз он другой природы: прежние два ловились правилом линтера про источник
утверждения, а этот — про **вход**: у сторожа утечки вход обязан содержать
значение, которое может утечь, иначе сторож пуст независимо от формы
утверждения. Механизации у этого нет и, похоже, быть не может: «может ли здесь
вообще утечь» — суждение, а не форма. Остаётся проходу ревью
## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
- **Где:** `internal/service/transcribe.go`, шаг конвертации — дефект завела та
же правка, что проложила контекст до `ffmpeg`
- **Симптом:** на прод не уехал, поймали до коммита. Выглядел бы так: обычная
выкладка посреди конвертации переводит здоровую запись в терминальное
`failed`, отправителю уходит «сбой конвертации файла», а вернуть задачу может
только владелец правкой в панели. Окно — часы: конвертация шестичасовой записи
идёт дольше часа по построению
- **Причина:** контекст дошёл до внешнего процесса, а различать его отмену шаг
не научили. Убитый по контексту `ffmpeg` отдаёт `signal: killed` — от
настоящего отказа (`exit status N`) эта ошибка неотличима ни типом, ни
`errors.Is`: различает только `ctx.Err()`. Шаг звал `failJob` на любой отказ
`Convert`. Хуже: `failJob` возвращает `nil`, поэтому воркер считал прогон
успешным, и метрика владельца — та, которой он замечает отказы, — не
шевелилась
- **Чем воспроизведён:** проверкой `TestShutdownDuringConversionKeepsJobRetryable`
с подставным конвертером, ведущим себя как убитый процесс: отдаёт отказ, не
несущий `context.Canceled`. Мутация снята — без развилки проверка краснеет
- **Почему не поймали раньше:** правка выглядела механической, «линтер потребовал
контекст». Цена оказалась в семантике очереди, а не в сигнатурах: отмена стала
значить разное на соседних шагах одного конвейера. Ни один линтер такого не
видит — это заметили три прохода ревью независимо, и все три построили путь
- **Что меняем:** прерванный шаг приговора не выносит — задача остаётся на
повтор, попытку не тратит (счётчик, выросший при захвате, возвращают назад) и
отправителю о несуществующем сбое не сообщает. Воркер не считает остановку
отказом и не пишет о ней владельцу. Задача не забирается вовсе, если нас уже
остановили. Остаток объявлен: норма отмены в спеке `pipeline` не описана, и
открытая задача `context-cancel-in-pipeline` этим закрыта не целиком
## 2026-08-13 — отказ скачивания уносил токен бота в журнал [проскочил]
- **Где:** `internal/controller/tg/tg.go`, скачивание записи по ссылке
`file.Link(c.bot.Token)`
- **Симптом:** не наблюдался, потому что журнал за этим местом никто не читал
построчно. Первый же сбой сети на скачивании писал в журнал
`Failed to download audio file` вместе с полным адресом запроса, а в адресе
Telegram держит токен бота (`…/bot<TOKEN>/…`). Инвариант «секрет не покидает
конфиг» помечен critical и необратим: утёкший токен отзывают руками
- **Причина:** `http.Get` возвращает `*url.Error`, и тот встраивает адрес
целиком. Отказ уходил в `fmt.Errorf("failed to download file: %w", err)`, а
оттуда — в `logger.Error` соседней строкой
- **Чем воспроизведён:** чтением цепочки от `http.Get` до вызова `logger.Error`
в трёх обработчиках; на живом боте не проверялся — боевым токеном запускаться
запрещено
- **Почему не поймали раньше:** правило было записано прозой и ровно про этот
случай — [conventions/logging.md](conventions/logging.md), «Ошибка
HTTP-транспорта несёт URL». Хуже: там же стояло объявленное *Расхождение* с
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
машина прозу не проверяет
- **Что меняем:** чистку перенесли с места употребления на **границу клиента**
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
больше не получает: клиента ему отдают готовым. Расхождение в конвенции
закрыто, оценка в [security.md](security.md) исправлена
- **Чем закрыт от возврата:** проверками `internal/adapter/telegram/bot_test.go`
— четыре пути (`getFile`, `sendMessage`, конструктор, логгер библиотеки)
судятся по тексту отказа и строке журнала. Мутация снята: со снятой чисткой
три из них краснеют, печатая токен. Правило остаётся прозой (линтер не отличит
ссылку с секретом от ссылки без него), но у прозы теперь есть оракул
- **Как нашли:** первый путь — попутно, при разборе находок `noctx`: тот
потребовал переписать `http.Get` на запрос с контекстом, и цепочку пришлось
прочитать целиком. Остальные четыре — конвейером ревью в тот же день; правка,
закрывшая один путь, объявила класс закрытым в двух документах, и это едва не
осталось так
## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]
- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока
результата распознавания
- **Симптом:** сегодня не наблюдался — путь рабочий, пока библиотека отдаёт конец
потока значением `io.EOF`. Отказ с текстом «EOF» был бы принят за конец потока,
и расшифровка вернулась бы усечённой: пользователь получил бы половину записи
как готовый результат
- **Причина:** конец потока узнавался сравнением `err.Error() == "EOF"`. Текст
сообщения — не признак: его носит и чужая ошибка, а сменит его библиотека —
условие перестанет срабатывать вовсе, и оба исхода молчаливы
- **Чем воспроизведён:** не воспроизводился на живом сервисе — прогон на реальных
ключах запрещён. Найден тестом-сканером `internal/archrules` при его заведении
- **Почему не поймали раньше:** `errorlint` видит `err == ErrX` и приведение типа,
но матчинг по тексту не видит; прозой это правило записано не было, и ревью его
не спрашивало
- **Что меняем:** узнавание переведено на `errors.Is(err, io.EOF)`; класс закрыт
тестом-сканером (docs/conventions/go-linters.md, «Ошибки и отказы»)
## 2026-08-13 — правило гейта обходилось одной лишней строкой [пойман ревью]
- **Где:** `.golangci.yml`, правило `forbidigo` о суждении по живой карте
заголовков — заведено в тот же день задачей `response-assertions-judge-result`
- **Симптом:** правило ловило только прямую цепочку `w.Header().Get`. Присваивание
в переменную (`h := w.Header()`), чтение по индексу карты, обход `range` и поле
`HeaderMap` проходили гейт зелёными — то есть класс, стоивший трёх зелёных
гейтов, возвращался четвёртый раз, и уже без человеческой страховки: документы
успели снять его с прохода ревью
- **Причина:** `forbidigo` по умолчанию судит по печатному тексту вызова, а не по
типу значения. Правило, записанное текстом, отсекает одну форму записи, а не
свойство
- **Чем воспроизведён:** прогоном линтера на файле проверок с шестью формами
чтения живой карты: помечена была одна
- **Почему не поймали раньше:** правило проверили ровно тем нарушением, против
которого писали. Мутация была, но одна — нужна была по одной на каждую форму
- **Что меняем:** правило судит по типу приёмника (`analyze-types`,
`httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
проверено мутацией по каждой. Урок записи: запрет по имени, обходимый лишней
строкой, свойства не держит — такому свойству нужен тест-сканер. Строка об этом
стояла в `docs/conventions/go-linters.md`, разделе «Лестница механизации»;
раздел снят 2026-08-13, урок остался здесь
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
- **Где:** шаг схемы `202608120001` задачи `oidc-login`, правило создания записи
в коллекции пользователей
- **Симптом:** `users.CreateRule = nil` закрывало создание записи для всех, кроме
владельца панели. Запись при первом входе заводит внутренний запрос самого
обмена, идущий без таких прав, — значит после выкладки вход не сработал бы ни
у кого, включая владельца, а приём и опрос уже были закрыты. Сервис остался бы
доступен только через Telegram, и чинилось бы это руками в панели
- **Причина:** закрывали ровно то, ради чего задача затевалась, — самостоятельную
регистрацию, которую хранилище приносит открытой. Глухое `nil` выглядит самым
надёжным её закрытием и отвергает заодно единственный законный путь заведения
записи. Различить их можно: обмен помечает свой запрос контекстом `oauth2`
- **Чем воспроизведён:** тестом против настоящего хранилища с подставным
провайдером: возврат от провайдера отвечал `401`, обращений к токен-эндпоинту
`1`, учётных записей после входа `0`. Причина изолирована тем же прогоном —
с открытым правилом возврат давал `302` и запись появлялась
- **Почему не поймали раньше:** все проверки задачи заводили учётную запись
прямым сохранением, мимо входа, и потому шли по коду, который в бою не
исполняется. Гейт был зелёным. Поймали два прохода независимо — разбор кода по
исходникам библиотеки и враждебный проход падающим тестом
- **Что меняем:** правило сузили до контекста обмена
(`@request.context = "oauth2"`), а в набор проверок добавили вход целиком через
подставного провайдера — от увода до куки сессии. Проверка, заводящая запись
мимо входа, больше не считается покрытием входа
## 2026-08-12 — проверка не могла упасть: читала живую карту заголовков вместо ответа [пойман ревью]
- **Где:** `internal/controller/http/auth_test.go`, проверка уборки носителя
состояния входа; сам дефект — в `auth.go`, уборка стояла в `defer`
- **Симптом:** носитель состояния и проверочного кода не убирался ни на успешном
возврате, ни на отказном, и жил свои десять минут. Одноразовость возврата
держалась ровно на этой уборке, то есть тоже не работала. Проверка при этом
была зелёной и утверждала обратное
- **Причина:** двойная. В коде — `defer` исполняется после того, как ответ уже
начали писать, а заголовки к этому моменту зафиксированы снимком, и позднейшая
правка их карты до браузера не доезжает. В проверке — `httptest` устроен
зеркально: `Header()` отдаёт живую карту, а снимок лежит отдельно и читается
через `Result()`. Проверка смотрела в живую карту и видела то, чего клиент не
получит
- **Чем воспроизведён:** отдельной программой вне проекта: на настоящем сервере
ответ приходил с пустым `Set-Cookie`, а тот же обработчик под `httptest`
показывал куку в `Header()` и не показывал в `Result()`
- **Почему не поймали раньше:** оракул был ложным по построению, и никакая
регрессия его не разбудила бы. Гейт зелёный. Поймали два прохода — сверка
требований и разбор кода, — оба воспроизведением, а не чтением
- **Что меняем:** уборка перенесена до записи ответа; все проверки этого файла
судят по `Result()`. Класс всплывает **третий раз** (2026-08-10 «тесты
http-обработчика ни разу не были зелёными», 2026-08-11 «проверка приёма не
могла упасть»), поэтому он же ушёл в конвенции правилом: проверка ответа
судит по готовому ответу, а не по изменяемому состоянию обработчика.
Механизировано 2026-08-12 задачей `response-assertions-judge-result`
`forbidigo` в `.golangci.yml` роняет гейт на чтении живой карты заголовков в
файле проверок. Правило судит по **типу приёмника**, а не по тексту вызова, и
потому ловит любую форму чтения живой карты — цепочкой, через переменную, по
индексу, обходом, полем `HeaderMap`. Текстовый запрет ловил только прямую
цепочку и обходился одной лишней строкой — это назвал прогон ревью этой же
задачи. Проходу ревью остаётся проверка, идущая мимо recorder, через свой
`http.ResponseWriter`
## 2026-08-12 — каждый анонимный запрос навсегда замедлял запись в хранилище [пойман ревью]
- **Где:** `internal/controller/http/auth.go`, обмен кода собирал роутер
хранилища на каждый вызов
- **Симптом:** сборка роутера вешает девять обработчиков на само приложение и
без идентификатора, поэтому повторная не заменяет прежние, а добавляет.
Обработчики исполняются на каждой записи в хранилище, а конвейер пишет задачу на
каждом шаге. Освобождения нет — только перезапуск. Раскачивалось анонимно:
атакующий ставит себе куку состояния сам, и сверка сравнивает две его же
величины, а обмен исполняется раньше обращения к провайдеру
- **Причина:** функция сборки выглядит чистой — она возвращает роутер, и по имени
не видно, что она правит приложение. Решение звать собственный адрес хранилища
внутри процесса сделало эту сборку частью горячего пути
- **Чем воспроизведён:** замером на настоящем приложении: пять вызовов подряд
подняли число обработчиков одного события с 4 до 14; 3000 анонимных возвратов
довели сотню сохранений записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. При
недоступном провайдере утечка сохранялась
- **Почему не поймали раньше:** ни один шаг гейта не смотрит на побочные эффекты
вызова библиотеки, а замер требует прогона. Поймали три прохода — архитектурный
зондом, враждебный падающим тестом, сверка требований чтением
- **Что меняем:** роутер собирается один раз и живёт полем обработчика; в набор
проверок добавлена та, что считает длину очереди обработчиков после двадцати
входов
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0`
её требует PocketBase, — а `Dockerfile` продолжал собирать на `golang:1.24-alpine`
с `GOTOOLCHAIN=local`. `task image` упал бы на шаге сборки: выкладки задачи
`pocketbase-storage` не существовало бы вовсе.
**Почему не поймали.** Все шесть проходов ревью и весь гейт видели зелёное:
`go build ./...` идёт на хостовом Go, а образ не собирает **ни один шаг гейта**.
Расхождение выглядело согласованным ещё и потому, что `CLAUDE.md` и `README.md`
обещали Go 1.24 — то есть три места из четырёх говорили одно и то же, и неверными
были именно они.
Нашлось не проходом, а триажем — при проверке чужих починок на месте, когда он
собрал образ руками. То есть поймано случайным свойством прогона, а не
устройством конвейера: проверь триаж починки чтением, дефект уехал бы в мердж.
**Чем чинится на будущее.** Сборка образа гейтом не проверяется намеренно —
дорого. Дешёвая замена: шаг, сверяющий версию сборщика в `Dockerfile` с
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
ревью.
**Закрыто** задачей `go-1-26-upgrade` 2026-08-12: шаг `go-version` в `task gate`
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
согласованно, и парная сверка не увидела бы документ, разошедшийся с
согласованным кодом. Нормативного дома у шага не осталось: спека `toolchain`
упразднена 2026-08-13, тогда же снесены и его двадцать сценариев — норма живёт
комментариями в самом скрипте.
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
об отказе шага
- **Симптом:** требование гласило «отказ MUST быть записан **ровно один раз**
единственной логирующей точкой». Сервис пишет дважды — сначала шаг конвейера,
следом воркер, — то есть норма не выполнялась бы с первого дня, а после
архивации стала бы посылкой для следующих задач
- **Причина:** дефект родился при починке соседнего. Первая редакция назначала
логирующей точкой воркера и фиксировала уровень `ERROR`, чем закрепляла
контрактом долг `conventions/logging.md`. Правка по этой находке ушла в
противоположную крайность: вместо «норма молчит о числе записей» получилось
«норма требует одной». Двойная запись — записанный системный долг, и обе
редакции с ним расходились, только в разные стороны
- **Чем воспроизведён:** прогоном пробы через `go test -overlay`: один отказ
хранилища даёт две записи — `Failed to find and acquire job` из шага и
`Worker error` из воркера
- **Почему не поймали раньше:** требование не имело сценария, а значит и оракула
— упасть ему было нечем. Ревью дизайна абзац читало, но код с ним не сверяло:
кода тогда не существовало. Поймал проход `specs` на ревью кода, направлением
`code → spec`, и поймал прогоном, а не чтением
- **Что меняем:** норма говорит только проверяемое сегодня — отказ виден
владельцу и засчитан в счётчик; число записей и уровень названы долгом с
адресом. В критерии приёмки добавлена строка: норма не объявляет обязательным
недостижимое — ни в ту, ни в другую сторону
## 2026-08-11 — хвост имени отправителя уезжал на открытую страницу метрик [пойман ревью]
- **Где:** `internal/service/transcribe.go`, метки `file_extension` у размера
принятой записи и `source_format` у длительности конвертации
- **Симптом:** имя `запись.тайное-слово` клало `тайное-слово` меткой метрики, а
`GET /metrics` отдаётся без проверки отправителя. Тем же каналом множеством значений
метки распоряжался анонимный отправитель
- **Причина:** расширение берётся из имени отправителя дословно (`filepath.Ext`)
и употреблялось меткой без приведения. Канал старше задачи, которая его нашла
- **Чем воспроизведён:** прогон `filepath.Ext` на именах вида
`запись.тайное-слово`, `Разговор с Петровым 11.08`, затем чтение реестра
метрик после приёма — метка несла хвост дословно
- **Почему не поймали:** метрику никто не считал выходом приватного значения.
Тема `security` смотрела журнал, ответ и пути на диске; вопроса про метку в
перечне вопросов не было, и ни один проход её не открывал. Поймали три прохода
разом на задаче, которая закрывала соседний канал
- **Что меняем:** вопрос про метку добавлен в «Вопросы по темам»; правило
приведения нормировано спекой `intake` и записано
[решением](adr/ADR-2026-08-11-known-format-label.md)
## 2026-08-11 — проверка приёма не могла упасть [пойман ревью]
- **Где:** `internal/controller/http/transcribe_test.go`, случай успеха приёма
- **Симптом:** тест не поймал ни одного настоящего дефекта приёма, хотя был
зелёным и выглядел содержательным
- **Причина:** две штуки одного рода. Тест разбирал ответ в
`CreateTranscribeJobResponse` — ту самую структуру, чьи теги `json` и
составляют публичный контракт: переименование тега меняло и проверяемое, и
ожидаемое разом. И заведение задачи тест подтверждал только эхом ответа, а не
чтением базы
- **Чем воспроизведён:** мутацией. Замена тега на `json:"jobId"` и удаление
`s.jobRepo.Create(job)` из `internal/service/transcribe.go` — тесты в обоих
случаях оставались зелёными; после правки обе мутации их роняют
- **Почему не поймали:** проверки писались тем же заходом, что и правились, а
«зелено» на новом тесте читается как подтверждение. Поймал проход `specs`
ревью кода, и поймал ровно тем, что добыл оракул мутацией, а не рассуждением
- **Что меняем:** успех судится по сырому JSON и по строке в базе. В типовые
узлы, «Любой узел», добавлено свойство «проверка способна упасть» с указанием
на мутацию как способ его проверить
## 2026-08-10 — тесты http-обработчика ни разу не были зелёными [проскочил]
- **Где:** `internal/controller/http/transcribe_test.go`
- **Симптом:** `go test ./...` падает четырьмя случаями; обнаружено первым же
прогоном гейта при заведении канона
- **Причина:** тест требует `testdata/sample.m4a`, которого в репозитории нет и
не могло быть — `.gitignore` содержит `*.m4a`. Остальные случаи записывают в
файл строку `test audio content` и ждут `201`, а обработчик зовёт настоящий
`ffprobe`, который такой вход отвергает
- **Чем воспроизведён:** `go test ./internal/controller/http/` — четыре отказа,
из них один по отсутствию файла и три по коду `500` вместо `201`
- **Почему не поймали:** гейта не было вовсе, а `go test` руками, судя по
результату, не гоняли ни разу с коммита `87d8b05`
- **Что меняем:** заведена задача `http-handler-tests-never-green`; в гейт
добавлен шаг `go test ./...`, и красный тест теперь виден. Настоящий остаток
шире: **тест, который никогда не проходил, обнуляет сигнал всего пакета** — в
типовые узлы добавлено свойство «покрыт хоть одним проходящим тестом», а в
вопросы темы `autotests` — вопрос про изменённый шаг конвейера
- **Закрыт** 2026-08-11: проверки переписаны, `go test ./...` зелёный и из
списка объявленных долгов в [CLAUDE.md](../CLAUDE.md) снят
## 2025-10-23 — пустой ответ вместо текста расшифровки [проскочил]
- **Где:** `internal/service/transcribe.go`, ветка завершения задачи
- **Симптом:** пользователь Telegram получал пустое сообщение вместо текста
- **Причина:** SpeechKit возвращал операцию успешной, но с пустым текстом, и
задача завершалась этим пустым значением
- **Чем воспроизведён:** восстановлено по коммиту `ec637c0`, оракула нет
- **Почему не поймали:** конвейера ревью не существовало
- **Что меняем:** уже сделано — пустой текст подменяется фразой «на записи нет
текста». Настоящий остаток в другом: свойство «вырожденный ответ внешнего
сервиса не превращается в успех молча» вынесено в типовой узел «клиент
внешнего сервиса» выше
## 2025-08-17 — длинная расшифровка не доходила до пользователя [проскочил]
- **Где:** `internal/adapter/telegram/sender.go`
- **Симптом:** отправка текста длиннее предела сообщения Telegram завершалась ошибкой
целиком, пользователь не получал ничего
- **Причина:** предел длины сообщения на стороне Telegram не учитывался
- **Чем воспроизведён:** восстановлено по коммиту `822e168`, который тем же
заходом завёл `internal/adapter/telegram/split_test.go`
- **Почему не поймали:** конвейера ревью не существовало
- **Что меняем:** уже сделано — деление по словам с пределом 4000 символов,
число записано в [database.md](database.md)
+469
View File
@@ -0,0 +1,469 @@
# Модель угроз
## Периметр
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
обратный прокси, а приём записи, чтение её карточки и текста и файл записи
требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
`trusted-header-login`, называет она его **заголовком, который ставит обратный
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
**каждый** запрос, и отзыв доступа действует со следующего.
Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton`
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
открытость не касается — всякий адрес под корнем приложения узнанного
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и
отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное
браузеру сервису нечем.
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
доступа — страницы расхода для владельца сервиса.
Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
вместе с этим исключением.
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
уходит содержимое записи» и «Что вне модели».
**Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
панели не существует, второго периметра на порту сервиса не осталось, и правилу
прокси нечего закрывать.
**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
секрет в настройки коллекции пользователей, и чтение файла базы становилось
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
и аноним войдёт под любым именем. Часть этой беды сервис закрывает сам:
запрос с двумя значениями `Remote-User` не узнаёт никого. **Закрыт при этом
только логин.** `Remote-Name` и `Remote-Email` берутся первым значением, то есть
присланным анонимом, и адрес почты, занятый им, закрепляется за чужой учётной
записью навсегда: колонка уникальна, а найденную запись узнавание не
переписывает. Прогон ревью 2026-08-23 построил этот путь и прогнал его;
правило решено распространить на всю тройку отдельной задачей. Остальное
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
репозитория `pet-project-server`, и **требование к нему такое — заголовки
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
человек.
**Изъятие из барьера одно — отладочный запуск, и заведено оно 2026-08-23**
задачей `config-test-headers-login`. При включённом предохранителе
`[server] debug` заголовки входа ставит не прокси, а сам сервис значениями из
секции `[auth.test_headers]`: на машине разработчика прокси нет, а браузер
заголовков не ставит. Узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает — отлаживается боевая ветка. Нормирует
изъятие спека [access](../openspec/specs/access/spec.md), решение о подстановке
самим сервисом —
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md).
Держится оно тремя вещами, и других нет: умолчание предохранителя —
«выключено»; заполненная имитация при выключенном предохранителе роняет старт с
именем ключа; боевой конфиг рендерится шаблоном Ansible, а не копируется с
машины разработчика. Подставленный заголовок проходит тот же барьер доверенного
адреса, что и пришедший, и судит адрес та же функция — но барьером отладочному
входу это не служит: перечень доверенных адресов включению предохранителя не
мешает.
**Боевая поломка машиной не исключена, и это названо прямо.** Сервис, поднятый в
бою с включённым предохранителем и заполненной имитацией, поднимется на любом
перечне доверенных адресов и назовёт своим именем всякого, чей запрос пришёл
через обратный прокси, — то есть всякого, кто пришёл обычным путём. Адресного
предохранителя у изъятия нет: требование петлевого перечня рассматривалось и
снято — [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
Как именно читается цепочка, нормирует спека
[archive](../openspec/specs/archive/spec.md), «Адреса приложения живут своим
пространством». Отсюда периметровое следствие: прокси, дописывающий
`X-Forwarded-For` к присланному, этим правилом покрыт, и требования
«перезаписывать, а не дописывать» у сервиса к нему нет — в отличие от `Remote-*`.
Барьером узнавания заголовок при этом не служит: кто пришёл, решает адрес самого
соединения.
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
именно поэтому.
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
после ухода прежнего владельца, и тогда новый человек при первом же обращении
попадает в **существующую** запись и получает весь её архив — самое
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
может: неизменяемого признака заголовок не приносит. Не допускать
переиспользования — работа провайдера, и это принятая цена, записанная в
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
нечем ни слить, ни убрать.
Отсюда главное следствие, из которого читается всё остальное: **`POST
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
же запросов ограничено только частотой**. Вошедший тратит наши деньги на
распознавание столько, сколько захочет: ограничитель частоты под корнем
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
по-прежнему нет — её заводит `per-user-size-quota`.
## Недоверенный вход
Что приходит извне и каким каналом.
| Вход | Канал | Кто может слать |
| --- | --- | --- |
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения. Слать тройку может ещё и сам сервис — при включённом предохранителе `[server] debug`, значением из настроек; изъятие целиком описано в «Периметре» выше |
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
Что добавится вместе с целевым периметром — каждый вход появляется своей
задачей, и до неё его нет:
| Вход | Канал | Кто может слать | Чья задача |
| --- | --- | --- | --- |
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
| Хеш-сумма файла | Поле запроса приёма | Отправитель — и она же решает, отдать ли прежнюю запись | `dedup-by-content-hash` |
Ответ языковой модели опаснее прочего в этом списке: он приходит текстом, идёт
в заголовок записи и оттуда на экран — то есть внешний сервис пишет то, что
увидит человек.
## Куда уходит содержимое записи
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
приложения.
Целевой периметр добавляет три пути, каждый — своей задачей:
| Куда | Что уходит | Чья задача |
| --- | --- | --- |
| Языковая модель за шлюзом bifrost | Текст расшифровки целиком | `llm-insights-adapter`, затем `literary-text-level` |
| Канал уведомлений (ntfy через apprise) | Готовый текст либо причина отказа | `ntfy-delivery` |
| Почтовый сервер | Готовый текст либо причина отказа, на адрес из учётной записи | `email-notification` |
Каждая из названных задач обязана оставить строку в этом разделе — там это
записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст
остаётся в приложении.
## Из чего строятся пути и ключи
Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога.
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался.
- **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
`<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла. Длина
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
роняет заведение временного файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет. С
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
распознавания.
- **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
журнал, ни в метку метрики, ни в ответ.
- **Адрес файла**`GET /app/audiorecords/{id}/file?copy=original|normalized`.
Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным
хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни
выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не
пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал
идёт расширение своим полем.
- **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же
единственное, что защищает карточку записи, её текст и её файл сверх владения.
- **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`,
`/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли
вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими
адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит
[webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто
везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md).
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
- **Хеш-сумма содержимого** (`dedup-by-content-hash`) становится ключом поиска
прежней записи. Ищется она **в пределах одного пользователя**: глобальный
поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и
заодно сообщал бы, что запись у кого-то уже есть.
- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же
плоский каталог — раскладка каталога данных меняется, и это необратимо.
- **Имя отправляемого документа** (`long-text-delivery`) собирается из
идентификатора задачи: имя, данное пользователем, в него не попадает.
## Что разграничивает доступ
- **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
отзыв доступа у Authelia действует со следующего обращения.
Собственных токенов сервис не принимает вовсе: значения, предъявленного
запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
значение било заголовок — им работал владелец панели; панели нет, и правило
приоритета осталось бы правилом без предмета.
**Область узнавания — корень приложения**, и выводится она из объявленного
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
базе, а первый такой запрос с новым именем — записи в неё.
- **Учётная запись** — заводится первым обращением с новым логином и находится
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
библиотечным правилом, не осталось.
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
- **Кто допущен****решает Authelia, а не сервис.** Своей проверки группы
приложение не делает: кого пускать, определяет правило провайдера на этого
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
его не проверить. Клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
2026-08-12.
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
больше нечего, и адресов этих не существует.
- **Метрики и здоровье**`GET /metrics` и `GET /health` открыты неузнанному:
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
учётной записи на них не заводит. Наружу их закрывает правило обратного
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
эти адреса не несут.
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
частоты на них нет: правило заведено под корень приложения, а раздача стоит
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
кто-то, — узнанному и неузнанному отдаётся одно и то же.
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
есть владелец. Знание идентификатора задачи правом её читать больше не является
— читает её тот, кто её принёс.
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
них уже стоит:
| Механизм | Что даёт | Чья задача |
| --- | --- | --- |
| Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
| Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
Откуда он берётся — из группы OIDC или из конфигурации — не решено
(`admin-stats-screen`).
**Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
всех пользователей мимо любого из механизмов разграничения, а пускал его свой
пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
сервиса осталось одно — владение записью.
Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
поверхности, открытой в интернет, у неё нет вовсе.
## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
`recognitions` (попытка распознавания; **сохранённый ответ провайдера —
полный текст речи — лежит файлом в подкаталоге записи**),
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`.
2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету.
Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
из базы вместе с собственным входом.
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
**Строки `.env` в `.gitignore` и в `.dockerignore` стоят без читателя, и снимать
их поэтому нельзя.** Читателя сняли 2026-08-23 вместе с зависимостью
`godotenv`: наш рабочий код окружение не читает, и файл, положенный рядом с
бинарником, ничего не меняет. Барьеры остались против другого — против того,
чтобы секрет завёлся в этом файле руками и уехал из него теми же двумя путями,
какими уехал бы из конфига: в git и в контекст сборки образа. Второй барьер
нужен отдельно от первого: `Dockerfile` копирует корень целиком (`COPY . .`), а
`.gitignore` docker не читает — чужой `.env` лёг бы слоем образа. Инвариант,
ради которого барьеры стоят, — «Секрет не покидает конфиг» из
[../CLAUDE.md](../CLAUDE.md).
Целевой периметр добавляет к списку пять записей, и первая из них — новый вид
секрета, которого сегодня в проекте нет вовсе:
1. **Токены пользователей** (`api-tokens`). Токен даёт права своего владельца
целиком. Срока жизни у него нет. В базе лежит только отпечаток, полное
значение показывается один раз при выпуске. Это первый секрет, который
хранится **в базе**, а не в конфигурации.
2. **Ключ языковой модели** и адрес шлюза bifrost (`llm-insights-adapter`).
Утечка оплачивается деньгами.
3. **Пароль почтового сервера** (`email-notification`).
4. **Адрес почты пользователя** — приходит от Authelia и хранится у нас
(`oidc-login`, `email-notification`).
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
открыта только владельцу.
Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
пропал, и ключа под него в конфигурации не заводится по той простой причине, что
заводить нечего.
Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
2026-08-11 задачей `no-user-filename-in-log`; запрет проверяют тесты приёма по HTTP на
успешном пути и на пути отказа — они ищут значение, а не имя поля.
**Хвост после последней точки остаётся в журнале, и это объявленное изъятие**
инварианта приватности из [../CLAUDE.md](../CLAUDE.md), а не незакрытый остаток.
Расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08`
`08`. В журнал оно идёт собственным полем, а не в составе имени файла: по нему
прослеживается путь записи. Читает этот журнал владелец сервиса. Нормализация
расширения в хранилище — отдельная работа, задачи на неё пока нет: формат имени
файла объявлен необратимым и меняется решением человека.
**Наружу хвост не выходит.** Метки метрик (`file_extension` у
`transcriber_input_file_size_bytes`, `source_format` у
`transcriber_conversion_duration_seconds`) несут расширение, только приведённое к
закрытому перечню известных форматов; всё прочее заменяется значением `other`.
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
по старому значению, покажет пустоту, и это не поломка.
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
распоряжался бы анонимный отправитель.
Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
клиента — закрыты задачами `no-user-filename-in-log` и
`local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
случая остался в [review.md](review.md) — он про класс, а не про Telegram.
Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
**шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ
пересобирается своими словами: путь, строка, столбец и последний ключ, без текста
библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому
проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же
работе. Правило — [conventions/config.md](conventions/config.md), «Секреты»;
оракулы — `internal/config/config_test.go`, проверки поломанного файла настроек.
Остаточный риск назван там же: разрез опирается на то, какое семейство отказов
несёт значения **в нынешней версии** библиотеки.
## Что вне модели
Перечислить явно.
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
Ansible — не наша граница.
- **Машина разработчика и то, что он на ней поднимает.** На место контура встаёт
сам сервис: при включённом предохранителе `[server] debug` он подставляет
заголовки входа значениями из конфига. Прежде эту роль играли отдельные
процессы — `cmd/oidcstub` с 2026-08-15 по 2026-08-22 и подкоманда
`cmd/devtools proxy` с 2026-08-22 по 2026-08-23; ни того, ни другой в
репозитории больше нет. Периметра выкладки отладочный запуск не касается,
пока предохранитель выключен, а выключен он по умолчанию; кто включил его у
себя в чужой сети, отвечает за это сам. В оснастке `cmd/devtools` осталась
одна подкоманда — `resume`, — и в образ она не едет: ступень собирает
`./cmd/transcriber` поимённо.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
SpeechKit не рассматривается.
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
(паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а
не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
— 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
нет — это граница домена, [passport.md](passport.md), «Учёт денег»; расход
считают `usage-accounting` и `admin-stats-screen`. Для модели угроз отсюда
следует одно: ни числом запросов, ни размером записи вошедший не ограничен, и
защищаться от исчерпания диска мы не пытаемся. Рост каталога данных при этом
ничем не наблюдается — открытый вопрос `architecture.md`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
оплачиваются по факту; потолка на пользователя нет по тому же решению.
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
внешней программе, своей песочницы вокруг неё нет.
- **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
строки потребления текста не содержат.
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
сперва строки приложений — журнал событий, попытка распознавания, структура,
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
кто убрал только файлы, стирает аудио и **оставляет полный текст речи**
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
`delete-record` это единственный способ, и он ручной целиком.
+34 -53
View File
@@ -1,83 +1,64 @@
module git.vakhrushev.me/av/transcriber
go 1.24.5
go 1.26.6
require (
github.com/BurntSushi/toml v1.5.0
github.com/aws/aws-sdk-go-v2 v1.37.2
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.30.3
github.com/aws/aws-sdk-go-v2/credentials v1.18.3
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0
github.com/doug-martin/goqu/v9 v9.19.0
github.com/gin-gonic/gin v1.10.1
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/smithy-go v1.27.7
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.31
github.com/pressly/goose/v3 v3.24.3
github.com/pressly/goose/v3 v3.27.3
github.com/prometheus/client_golang v1.23.0
github.com/samber/slog-gin v1.15.1
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
github.com/yandex-cloud/go-genproto v0.17.0
google.golang.org/grpc v1.74.2
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.57.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
github.com/aws/smithy-go v1.22.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.9 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sethvargo/go-retry v0.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/protobuf v1.36.7 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+103 -166
View File
@@ -1,11 +1,9 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.30.3 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc=
github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI=
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 h1:ptfyXmv+ooxzFwyuBth0yqABcjVIkjDL0iTYZBSbum8=
@@ -14,222 +12,161 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 h1:nRniHAvjFJGUCl04F3WaAj7
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2/go.mod h1:eJDFKAMHHUvv4a0Zfa7bQb//wFNUXGrbFpYRCHe2kD0=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 h1:Nb2pUE30lySKPGdkiIJ1SZgHsjiebOiRNI7R9NA1WtM=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3/go.mod h1:BO5EKulvhBF1NXwui8lfnuDPBQQU5807yvWASZ/5n6k=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 h1:sBpc8Ph6CpfZsEdkz/8bfg8WhKlWMCms5iWj6W/AW2U=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2/go.mod h1:Z2lDojZB+92Wo6EKiZZmJid9pPrDJW2NNIXSlaEfVlU=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 h1:blV3dY6WbxIVOFggfYIo2E1Q2lZoy5imS7nKgu5m6Tc=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2/go.mod h1:cBWNeLBjHJRSmXAxdS7mwiMUEgx6zup4wQ9J+/PcsRQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 h1:oxmDEO14NBZJbK/M8y3brhMFEIGN4j8a6Aq8eY0sqlo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2/go.mod h1:4hH+8QCrk1uRWDPsVfsNDUup3taAjO8Dnx63au7smAU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 h1:0hBNFAPwecERLzkhhBY+lQKUMpXSKVv4Sxovikrioms=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2/go.mod h1:Vcnh4KyR4imrrjGN7A2kP2v9y6EPudqoPKXtnmBliPU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 h1:utPhv4ECQzJIUbtx7vMN4A8uZxlQ5tSt1H1toPI41h8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0/go.mod h1:1/eZYtTWazDgVl96LmGdGktHFi7prAcGCrJ9JGvBITU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 h1:j7/jTOjWeJDolPwZ/J4yZ7dUsxsWZEsxNwH5O7F8eEA=
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0/go.mod h1:M0xdEPQtgpNT7kdAX4/vOAPkFj60hSQRb7TvW9B0iug=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2g1RDHlvri3NxHA08MGI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E=
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M=
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo=
github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.31 h1:ldt6ghyPJsokUIlksH63gWZkG6qVGeEAu4zLeS4aVZM=
github.com/mattn/go-sqlite3 v1.14.31/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM=
github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+5 -3
View File
@@ -1,6 +1,7 @@
package ffmpeg
import (
"context"
"fmt"
"os"
"os/exec"
@@ -15,7 +16,7 @@ func NewFfmpegConverter() *FfmpegConverter {
return &FfmpegConverter{}
}
func (c *FfmpegConverter) Convert(src, dest string) error {
func (c *FfmpegConverter) Convert(ctx context.Context, src, dest string) error {
// Проверяем существование исходного файла
if _, err := os.Stat(src); os.IsNotExist(err) {
return fmt.Errorf("input file does not exist: %s", src)
@@ -26,8 +27,9 @@ func (c *FfmpegConverter) Convert(src, dest string) error {
return fmt.Errorf("ffmpeg not found in PATH: %w", err)
}
// Создаем команду ffmpeg для конвертации в OGG
cmd := exec.Command(ffmpegExecutable,
// Команда заводится с контекстом: отменённый контекст убивает процесс, а не
// оставляет его дожёвывать чужую запись после остановки воркера.
cmd := exec.CommandContext(ctx, ffmpegExecutable,
"-i", src, // входной файл
"-c:a", "libvorbis", // кодек Vorbis для OGG
"-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество)
+5 -3
View File
@@ -1,6 +1,7 @@
package ffmpeg
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -26,7 +27,7 @@ func NewFfmpegMetaViewer() *FfmpegMetaViewer {
return &FfmpegMetaViewer{}
}
func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
func (m *FfmpegMetaViewer) GetInfo(ctx context.Context, src string) (*contract.AudioInfo, error) {
// Проверяем существование исходного файла
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil, fmt.Errorf("input file does not exist: %s", src)
@@ -37,8 +38,9 @@ func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
}
// Создаем команду ffprobe для получения метаданных
cmd := exec.Command(ffprobeExecutable,
// Команда заводится с контекстом: отправитель, закрывший соединение, не
// оставляет за собой чтение метаданных чужого файла.
cmd := exec.CommandContext(ctx, ffprobeExecutable,
"-v", "quiet", // тихий режим (без лишнего вывода)
"-print_format", "json", // вывод в формате JSON
"-show_format", // показать информацию о формате
+65 -7
View File
@@ -1,22 +1,80 @@
package recognizer
import (
"context"
"encoding/json"
"errors"
"io"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/google/uuid"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// MemoryAudioRecognizer — подставной распознаватель для местного запуска и
// проверок. Прогон на реальных ключах ради проверки кода запрещён: распознавание
// и хранение в Object Storage оплачиваются по факту.
//
// Сырой ответ он отдаёт своего вида, но настоящего: тем же путём, что и живой
// адаптер, — сохранённые байты разбираются обратно в реплики, и структура
// строится без единого обращения наружу.
type MemoryAudioRecognizer struct{}
func (r *MemoryAudioRecognizer) Recognize(file io.Reader, fileName string) (operationID string, err error) {
const memoryProvider = "memory"
func (r *MemoryAudioRecognizer) Provider() string { return memoryProvider }
func (r *MemoryAudioRecognizer) Model() string { return "memory" }
func (r *MemoryAudioRecognizer) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
return "memory://" + objectKey, nil
}
func (r *MemoryAudioRecognizer) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
return false, nil
}
func (r *MemoryAudioRecognizer) Submit(ctx context.Context, sourceURI string) (string, error) {
return uuid.NewString(), nil
}
func (r *MemoryAudioRecognizer) GetRecognitionText(operationID string) (string, error) {
return "Foo bar, Baz.", nil
}
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
func (r *MemoryAudioRecognizer) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
return entity.NewCompletedResult(), nil
}
func (r *MemoryAudioRecognizer) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
replicas := []entity.Replica{
{StartMs: 0, EndMs: 1000, Text: "Foo bar,"},
{StartMs: 1000, EndMs: 2000, Text: "Baz."},
}
raw, err := json.Marshal(replicas)
if err != nil {
return nil, errors.New("failed to encode memory payload")
}
return &entity.RecognitionOutcome{
Replicas: replicas,
PlainText: "Foo bar, Baz.",
Raw: raw,
}, nil
}
func (r *MemoryAudioRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
var replicas []entity.Replica
if err := json.Unmarshal(raw, &replicas); err != nil {
return nil, errors.New("failed to decode memory payload")
}
var plain []byte
for _, replica := range replicas {
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, replica.Text...)
}
return &entity.RecognitionOutcome{
Replicas: replicas,
PlainText: string(plain),
Raw: raw,
}, nil
}
@@ -0,0 +1,138 @@
package yandex
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
"google.golang.org/protobuf/proto"
)
// Сохранённый ответ провайдера — единственное, из чего пересчитывается архив:
// результат операции у SpeechKit не переспрашивается, и повторное распознавание
// стоит денег. Поэтому проверки ниже судят не «разбор чего-то вернул», а
// сохранность самого ответа.
// response собирает ответ потока с одной репликой.
func response(text string, start, end int64) *stt.StreamingResponse {
return &stt.StreamingResponse{
Event: &stt.StreamingResponse_FinalRefinement{
FinalRefinement: &stt.FinalRefinement{
Type: &stt.FinalRefinement_NormalizedText{
NormalizedText: &stt.AlternativeUpdate{
Alternatives: []*stt.Alternative{
{Text: text, StartTimeMs: start, EndTimeMs: end},
},
},
},
},
},
}
}
// Сохранённое читается обратно тем же: реплики со временем и плоский текст.
func TestPayloadSurvivesRoundTrip(t *testing.T) {
responses := []*stt.StreamingResponse{
response("Первая реплика.", 0, 900),
response("Вторая реплика.", 900, 1800),
}
raw, err := encodeResponses(responses)
require.NoError(t, err)
require.NotEmpty(t, raw)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
require.Len(t, decoded, 2)
outcome := outcomeFromResponses(decoded)
require.Len(t, outcome.Replicas, 2)
assert.Equal(t, "Первая реплика.", outcome.Replicas[0].Text)
assert.Equal(t, int64(0), outcome.Replicas[0].StartMs)
assert.Equal(t, int64(900), outcome.Replicas[0].EndMs)
assert.Equal(t, "Вторая реплика.", outcome.Replicas[1].Text)
assert.Equal(t, "Первая реплика. Вторая реплика.", outcome.PlainText)
}
// Ради этого свойства сохранение и сделано двоичным. Провайдер добавляет поля
// без предупреждения, и текстовое представление, собранное по нашей
// скомпилированной схеме, выбросило бы их молча — а пересчитать архив было бы
// уже не из чего: операция не переспрашивается.
func TestUnknownProviderFieldSurvivesStorage(t *testing.T) {
original := response("Реплика.", 0, 500)
// Так выглядит поле, которого наша схема не знает: провайдер прислал его,
// разбор положил в неизвестные.
unknown := protoimplUnknown(t)
original.ProtoReflect().SetUnknown(unknown)
require.NotEmpty(t, original.ProtoReflect().GetUnknown(), "неизвестное поле поставлено")
raw, err := encodeResponses([]*stt.StreamingResponse{original})
require.NoError(t, err)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
require.Len(t, decoded, 1)
assert.Equal(t, []byte(unknown), []byte(decoded[0].ProtoReflect().GetUnknown()),
"неизвестное провайдерское поле пережило запись и чтение")
// И известное при этом на месте.
outcome := outcomeFromResponses(decoded)
require.Len(t, outcome.Replicas, 1)
assert.Equal(t, "Реплика.", outcome.Replicas[0].Text)
}
// Обрезанное вложение узнаётся отказом, а не половиной расшифровки: половина
// текста, выданная за целую, тише и хуже отказа.
func TestTruncatedPayloadIsRefused(t *testing.T) {
raw, err := encodeResponses([]*stt.StreamingResponse{response("Реплика.", 0, 500)})
require.NoError(t, err)
require.Greater(t, len(raw), 2)
_, err = decodeResponses(raw[:len(raw)-2])
assert.Error(t, err, "обрезанное вложение не разбирается молча")
}
// Пустой поток даёт пустой результат, а не отказ: «на записи нет текста» —
// законный исход распознавания.
func TestEmptyStreamGivesEmptyOutcome(t *testing.T) {
raw, err := encodeResponses(nil)
require.NoError(t, err)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
assert.Empty(t, decoded)
outcome := outcomeFromResponses(decoded)
assert.Empty(t, outcome.Replicas)
assert.Empty(t, outcome.PlainText)
}
// Ответ без разбора текста реплик не даёт и разбор не роняет: провайдер шлёт по
// потоку и служебные события.
func TestResponseWithoutTextIsSkipped(t *testing.T) {
responses := []*stt.StreamingResponse{
{Event: &stt.StreamingResponse_FinalRefinement{FinalRefinement: &stt.FinalRefinement{}}},
response("Реплика.", 0, 500),
response("", 500, 600),
}
outcome := outcomeFromResponses(responses)
require.Len(t, outcome.Replicas, 1, "пустые и служебные события репликами не становятся")
assert.Equal(t, "Реплика.", outcome.PlainText)
}
// protoimplUnknown собирает байты неизвестного поля: номер поля, которого в
// нашей схеме нет, с целочисленным значением.
func protoimplUnknown(t *testing.T) []byte {
t.Helper()
// Поле 4095, тип varint, значение 7 — заведомо за пределами схемы ответа.
raw, err := proto.Marshal(&stt.StreamingResponse{})
require.NoError(t, err)
require.Empty(t, raw)
return []byte{0xF8, 0xFF, 0x3F, 0x07}
}
@@ -1,12 +1,18 @@
package yandex
import (
"context"
"fmt"
"io"
"time"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// ProviderName — имя провайдера, под которым сохраняется попытка распознавания.
// По нему видно, чем считана запись, когда провайдеров станет больше одного.
const ProviderName = "yandex-speechkit"
type YandexAudioRecognizerConfig struct {
// s3
Region string
@@ -54,29 +60,74 @@ func (s *YandexAudioRecognizerService) Close() error {
return s.sttService.Close()
}
func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string) (string, error) {
func (s *YandexAudioRecognizerService) Provider() string { return ProviderName }
err := s.s3Sevice.uploadFile(file, fileName)
if err != nil {
func (s *YandexAudioRecognizerService) Model() string { return RecognitionModel }
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
// прежде, чем ответ дойдёт, и защита ничего не даст.
const startRecognitionTimeout = 10 * time.Second
// Upload кладёт аудио туда, откуда провайдер его прочитает.
//
// Отменяется штатно: заливка дорога по времени, а повтор её бесплатен — объект
// ложится под тем же ключом.
func (s *YandexAudioRecognizerService) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
if err := s.s3Sevice.uploadFile(ctx, file, objectKey); err != nil {
return "", err
}
return s.s3Sevice.fileUrl(objectKey), nil
}
uri := s.s3Sevice.fileUrl(fileName)
// ObjectExists отвечает, лежит ли объект нужного размера.
//
// Сверка идёт по присутствию и длине, а не по отпечатку содержимого: признак
// целостности у составного объекта не равен отпечатку, и сверка хешем дала бы
// расхождение на всякой большой записи.
func (s *YandexAudioRecognizerService) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
return s.s3Sevice.objectExists(ctx, objectKey, size)
}
opId, err := s.sttService.recognizeFileFromS3(uri)
// Submit заводит операцию распознавания. Оплачивается наружу, поэтому от отмены
// защищён: окно короткое и дорогое — SpeechKit может операцию принять и начать
// считать деньги, а ответ до нас не доедет, и повтор оплатит ту же запись второй
// раз. Свой предел вызову оставлен, чтобы остановка не ждала вечно.
func (s *YandexAudioRecognizerService) Submit(ctx context.Context, sourceURI string) (string, error) {
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
defer cancel()
return s.sttService.recognizeFileFromS3(startCtx, sourceURI)
}
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
// родителя и собственный предел по времени. Употребляется там, где обрыв стоит
// дороже ожидания: у платной операции, чей результат нельзя переспросить.
func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
}
// Fetch забирает готовый результат и отдаёт его доменным: реплики со временем,
// плоский текст и байты ответа на хранение. Формата провайдера наружу не выходит
// ничего — ни один шаг конвейера не знает, каким потоком тот отвечает.
func (s *YandexAudioRecognizerService) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
return s.sttService.fetchRecognition(ctx, operationID)
}
// Parse строит доменный результат из **сохранённого** ответа, не обращаясь к
// провайдеру. По нему архив пересчитывается без единого рубля.
func (s *YandexAudioRecognizerService) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
responses, err := decodeResponses(raw)
if err != nil {
return "", err
return nil, err
}
return opId, nil
outcome := outcomeFromResponses(responses)
outcome.Raw = raw
return outcome, nil
}
func (s *YandexAudioRecognizerService) GetRecognitionText(operationID string) (string, error) {
return s.sttService.getRecognitionText(operationID)
}
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
operation, err := s.sttService.checkOperationStatus(operationID)
func (s *YandexAudioRecognizerService) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
if err != nil {
return nil, err
}
@@ -0,0 +1,38 @@
package yandex
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Принятие операции распознавания защищено от отмены: остановка сервиса не
// должна обрывать вызов, который уже мог начать стоить денег и чей результат
// нельзя переспросить. Проверяется само средство защиты — проводка к нему
// оракула не имеет: клиент SpeechKit подставить нечем, а прогон на реальных
// ключах запрещён (CLAUDE.md, «Запреты»).
func TestProtectedContextSurvivesParentCancel(t *testing.T) {
parent, cancel := context.WithCancel(t.Context())
protected, release := protectFromCancel(parent, time.Minute)
defer release()
cancel()
require.Error(t, parent.Err(), "родитель отменён — иначе проверка судит не то")
assert.NoError(t, protected.Err(), "защищённый вызов пережил отмену родителя")
}
// Защита не бессрочна: у вызова свой предел, иначе остановка ждала бы вечно.
func TestProtectedContextKeepsItsOwnDeadline(t *testing.T) {
protected, release := protectFromCancel(t.Context(), time.Minute)
defer release()
deadline, ok := protected.Deadline()
require.True(t, ok, "у защищённого вызова обязан быть свой предел")
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, 5*time.Second)
}
+46 -3
View File
@@ -2,6 +2,7 @@ package yandex
import (
"context"
"errors"
"fmt"
"io"
"strings"
@@ -11,6 +12,8 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
type s3Config struct {
@@ -65,19 +68,59 @@ func newYandexS3Service(cfg s3Config) (*yandexS3Service, error) {
}, nil
}
func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
_, err := s.uploader.Upload(context.Background(), &s3.PutObjectInput{
func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileName string) error {
_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(fileName),
Body: file,
})
if err != nil {
return fmt.Errorf("failed to upload file to S3: %w", err)
// Отказ SDK несёт полный URL объекта, то есть имя файла в хранилище, а
// оно — последняя часть ссылки на скачивание: цепочка `%w` уехала бы в
// журнал вместе с ключом. Наружу идёт класс отказа и только он — по
// нему «ключи отозваны» отличимо от «бакета нет» и от «сети нет», а
// адреса в коде отказа SDK не бывает.
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
return fmt.Errorf("failed to upload file to S3: %s", apiErr.ErrorCode())
}
return errors.New("failed to upload file to S3")
}
return nil
}
// objectExists отвечает, лежит ли объект нужного размера.
//
// По нему шаг решает, повторять ли заливку: повтор её бесплатен, но дорог по
// времени на многочасовой записи. Сверка идёт по присутствию и длине, а не по
// отпечатку содержимого: признак целостности у составного объекта не равен
// отпечатку, и сверка хешем расходилась бы на всякой большой записи.
func (s *yandexS3Service) objectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(objectKey),
})
if err != nil {
var notFound *s3types.NotFound
if errors.As(err, &notFound) {
return false, nil
}
// Отказ SDK несёт полный URL объекта, а он — ключ к чужому аудио: наружу
// идёт класс отказа и только он.
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("failed to head object in S3: %s", apiErr.ErrorCode())
}
return false, errors.New("failed to head object in S3")
}
return out.ContentLength != nil && *out.ContentLength == size, nil
}
func (s *yandexS3Service) fileUrl(fileName string) string {
endpoint := strings.TrimRight(s.endpoint, "/")
return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName)
+134 -32
View File
@@ -2,15 +2,20 @@ package yandex
import (
"context"
"encoding/binary"
"errors"
"fmt"
"strings"
"io"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
"github.com/yandex-cloud/go-genproto/yandex/cloud/operation"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
const (
@@ -52,8 +57,16 @@ func newSpeechKitService(cfg speechKitConfig) (*speechKitService, error) {
// Создаем защищенное соединение для Operations API
opConn, err := grpc.NewClient(OperationEndpoint, grpc.WithTransportCredentials(creds))
if err != nil {
sttConn.Close()
return nil, fmt.Errorf("failed to connect to Operations API: %w", err)
// Отказы независимы, и второй не теряется. На сегодняшнем клиенте он
// почти наверняка не наступит: grpc.NewClient ленив, соединение к этому
// моменту не открыто, и Close вернёт отказ только при повторном
// закрытии — то есть сообщит о нашей ошибке, а не о Yandex. Сборка
// оставлена как защита от смены реализации клиента; nil от закрытия
// errors.Join отбрасывает, и форма ошибки в обычном случае не меняется.
return nil, errors.Join(
fmt.Errorf("failed to connect to Operations API: %w", err),
sttConn.Close(),
)
}
sttClient := stt.NewAsyncRecognizerClient(sttConn)
@@ -77,16 +90,14 @@ func (s *speechKitService) Close() error {
if s.opConn != nil {
err2 = s.opConn.Close()
}
if err1 != nil {
return err1
}
return err2
// Отказы двух соединений независимы, и вернуть только первый — значит
// потерять половину причины: журнал пишется при остановке процесса, и
// восстановить утраченное будет уже негде.
return errors.Join(err1, err2)
}
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
ctx := context.Background()
func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string) (string, error) {
// Добавляем авторизацию и folder_id в контекст
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -126,11 +137,13 @@ func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
return op.Id, nil
}
// GetRecognitionResult получает результат распознавания по ID операции
func (s *speechKitService) getRecognitionText(operationID string) (string, error) {
ctx := context.Background()
// Добавляем авторизацию и folder_id в контекст
// fetchRecognition забирает результат операции целиком и отдаёт его доменным,
// вместе с сырым ответом на хранение.
//
// Ответ сохраняется потому, что **результат операции у провайдера не
// переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и
// когда научится, архив пересчитается из сохранённого без единого рубля.
func (s *speechKitService) fetchRecognition(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -140,36 +153,36 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
stream, err := s.sttClient.GetRecognition(ctx, req)
if err != nil {
return "", fmt.Errorf("failed to get recognition stream: %w", err)
return nil, fmt.Errorf("failed to get recognition stream: %w", err)
}
var sb strings.Builder
var responses []*stt.StreamingResponse
for {
resp, err := stream.Recv()
if err != nil {
if err.Error() == "EOF" {
// Конец потока библиотека отдаёт ровно `io.EOF`. Прежде он узнавался
// сравнением текста сообщения: так же выглядел бы и настоящий отказ
// с текстом «EOF», и распознавание молча вернуло бы половину текста.
if errors.Is(err, io.EOF) {
break
}
return "", fmt.Errorf("failed to receive recognition response: %w", err)
}
if refinement := resp.GetFinalRefinement(); refinement != nil {
if text := refinement.GetNormalizedText(); text != nil {
for _, alt := range text.Alternatives {
sb.WriteString(alt.Text)
sb.WriteString(" ")
}
}
return nil, fmt.Errorf("failed to receive recognition response: %w", err)
}
responses = append(responses, resp)
}
return sb.String(), nil
raw, err := encodeResponses(responses)
if err != nil {
return nil, err
}
outcome := outcomeFromResponses(responses)
outcome.Raw = raw
return outcome, nil
}
// checkOperationStatus проверяет статус операции распознавания
func (s *speechKitService) checkOperationStatus(operationID string) (*operation.Operation, error) {
ctx := context.Background()
func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID string) (*operation.Operation, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -183,3 +196,92 @@ func (s *speechKitService) checkOperationStatus(operationID string) (*operation.
return op, nil
}
// encodeResponses укладывает ответ провайдера целиком, в том виде, в каком он
// пришёл: сообщения потока подряд, каждое со своей длиной впереди.
//
// Форма **двоичная**, а не текстовая, и это несущее решение. Текстовое
// представление собирается по нашей скомпилированной схеме и молча выбрасывает
// поля, которых в ней нет, — а провайдер добавляет их без предупреждения.
// Двоичная форма неизвестные поля переносит: они переживают запись и чтение и
// станут читаемыми, когда мы обновим схему. Ради этого архив и заводился —
// результат операции у провайдера не переспрашивается, и повторное
// распознавание стоит денег.
//
// Цена названа прямо: сохранённое не читается глазами и не разбирается ничем,
// кроме нашего же кода.
func encodeResponses(responses []*stt.StreamingResponse) ([]byte, error) {
var raw []byte
for _, resp := range responses {
encoded, err := proto.Marshal(resp)
if err != nil {
// Текст расшифровки наружу не выходит даже отказом: сообщение
// провайдера несёт её целиком.
return nil, errors.New("failed to encode provider response")
}
raw = binary.AppendUvarint(raw, uint64(len(encoded)))
raw = append(raw, encoded...)
}
return raw, nil
}
// decodeResponses читает сохранённый ответ провайдера обратно.
func decodeResponses(raw []byte) ([]*stt.StreamingResponse, error) {
var responses []*stt.StreamingResponse
for len(raw) > 0 {
size, read := binary.Uvarint(raw)
if read <= 0 || uint64(len(raw)-read) < size {
return nil, errors.New("stored provider payload is truncated")
}
raw = raw[read:]
var resp stt.StreamingResponse
if err := proto.Unmarshal(raw[:size], &resp); err != nil {
return nil, errors.New("failed to decode provider response")
}
responses = append(responses, &resp)
raw = raw[size:]
}
return responses, nil
}
// outcomeFromResponses строит доменный результат: реплики со временем и плоский
// текст. Формата провайдера отсюда наружу не выходит ничего.
//
// Говорящие не размечаются: связь реплики с разбором говорящего у провайдера не
// выяснена. Структура при этом строится из сохранённого ответа, поэтому разметка
// станет возможной без повторной оплаты.
func outcomeFromResponses(responses []*stt.StreamingResponse) *entity.RecognitionOutcome {
outcome := &entity.RecognitionOutcome{}
var plain []byte
for _, resp := range responses {
refinement := resp.GetFinalRefinement()
if refinement == nil {
continue
}
text := refinement.GetNormalizedText()
if text == nil {
continue
}
for _, alt := range text.GetAlternatives() {
if alt.GetText() == "" {
continue
}
outcome.Replicas = append(outcome.Replicas, entity.Replica{
StartMs: alt.GetStartTimeMs(),
EndMs: alt.GetEndTimeMs(),
Text: alt.GetText(),
})
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, alt.GetText()...)
}
}
outcome.PlainText = string(plain)
return outcome
}
+172
View File
@@ -0,0 +1,172 @@
// Package sqlite — хранилище сервиса: база на своей схеме и файлы записей своим
// каталогом.
//
// Пакет назван по драйверу, а не по роли: соседи в `internal/adapter` названы
// тем же способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite»
// читается как «репозитории поверх SQLite» без знания кода.
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
// Драйвер регистрируется загрузкой пакета. CGO ему не нужен — этим он и
// выбран: сборка бинарника остаётся без компилятора C.
_ "modernc.org/sqlite"
)
// driverName — имя, под которым драйвер регистрируется в `database/sql`.
const driverName = "sqlite"
// DatabaseFile — имя файла базы в каталоге данных. Рядом с ним драйвер кладёт
// журнал упреждающей записи и его указатель, поэтому каталог данных занят базой
// целиком, а не одним файлом.
const DatabaseFile = "transcriber.db"
// Settings — числа, которыми настраивается база. Оба приходят настройкой, а не
// константой кода: крутят их при одном и том же отказе — «база занята» под
// несколькими воркерами, — и подбор ответа на такой отказ не должен требовать
// пересборки образа.
type Settings struct {
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
BusyTimeoutMs int
// ReadConnections — сколько соединений держит читающий пул.
ReadConnections int
}
// Validate проверяет числа базы. Ноль и отрицательное — опечатка, а не режим:
// нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
// чтения означает пул без предела, то есть настройку, которой не управляют.
func (s Settings) Validate() error {
if s.BusyTimeoutMs <= 0 {
return errors.New("storage: ожидание занятой базы задаётся положительным числом миллисекунд")
}
if s.ReadConnections <= 0 {
return errors.New("storage: число соединений читающего пула задаётся положительным числом")
}
return nil
}
// Обращения к базе идут с **собственным** контекстом, а не с контекстом
// запроса, и это решение, а не недосмотр. Репозитории отменять нечего: операции
// местные и короткие, а единственное ожидание — занятая база — задано числом. За
// отмену при этом платили бы дважды: шаг, прерванный остановкой сервиса,
// перестал бы освобождать захват и писать причину остановки — то есть отмена
// ломала бы ровно ту уборку, ради которой она и делается.
//
// Отмена, которой сервис распоряжается по-настоящему, доходит туда, где она
// стоит денег и времени: до `ffmpeg` и до платного распознавания.
// DB — база сервиса двумя пулами.
//
// Пишущий пул держит **одно** соединение: драйвер пишет единственным
// соединением, и несколько воркеров, пришедших писать разом мимо этого правила,
// получают отказ по занятости — на записи результата шага, то есть после
// оплаченной работы. Пул с одним соединением обращает их в очередь.
//
// Читающий пул отдельный: в журнале упреждающей записи читатели не мешают
// писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
type DB struct {
// writer — единственное пишущее соединение. Через него идёт всякая
// операция, которая читает состояние и следом его пишет: транзакцию,
// начатую на читающем соединении, SQLite до пишущей не повышает и отвечает
// отказом по занятости немедленно — заданное числом ожидание такой отказ не
// лечит, ждать там нечего.
writer *sql.DB
// reader — пул чтения.
reader *sql.DB
}
// Writer отдаёт пишущее соединение.
func (db *DB) Writer() *sql.DB { return db.writer }
// Reader отдаёт читающий пул.
func (db *DB) Reader() *sql.DB { return db.reader }
// Open открывает базу в каталоге данных, заводя каталог, если его ещё нет.
//
// Настройки соединения задаются **строкой подключения обоих пулов**, а не
// запросом после открытия. Соблюдение внешних ключей в SQLite — настройка
// соединения, а не базы, и по умолчанию она выключена; пул раздаёт соединения и
// заводит новые по мере надобности, поэтому запрос, выполненный один раз,
// настроил бы одно соединение из многих, а остальные остались бы с умолчанием —
// молча.
func Open(dataDir string, settings Settings) (*DB, error) {
if err := settings.Validate(); err != nil {
return nil, err
}
if err := os.MkdirAll(dataDir, 0o750); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
path := filepath.Join(dataDir, DatabaseFile)
// Пишущее соединение начинает транзакцию сразу пишущей (`immediate`):
// операция, которая читает и следом пишет, иначе взяла бы читающую
// транзакцию и упёрлась бы в отказ при первой же записи.
writer, err := open(path, settings, "immediate")
if err != nil {
return nil, err
}
writer.SetMaxOpenConns(1)
writer.SetMaxIdleConns(1)
reader, err := open(path, settings, "deferred")
if err != nil {
return nil, errors.Join(fmt.Errorf("failed to open read pool: %w", err), writer.Close())
}
reader.SetMaxOpenConns(settings.ReadConnections)
reader.SetMaxIdleConns(settings.ReadConnections)
db := &DB{writer: writer, reader: reader}
// Пробное обращение делается сразу: `sql.Open` соединения не открывает, и
// негодная строка подключения вылезла бы не на старте, а на первом запросе —
// то есть отказом каждого запроса вместо одной строки о причине.
if err := writer.PingContext(context.Background()); err != nil {
return nil, errors.Join(fmt.Errorf("failed to open database: %w", err), db.Close())
}
return db, nil
}
// open заводит один пул с общими настройками соединения.
func open(path string, settings Settings, txlock string) (*sql.DB, error) {
query := url.Values{}
query.Add("_pragma", "busy_timeout("+strconv.Itoa(settings.BusyTimeoutMs)+")")
query.Add("_pragma", "journal_mode(WAL)")
query.Add("_pragma", "foreign_keys(1)")
query.Set("_txlock", txlock)
db, err := sql.Open(driverName, "file:"+path+"?"+query.Encode())
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return db, nil
}
// Close закрывает оба пула. Повторный вызов паники не даёт: закрытие уже
// закрытого пула отказом не считается.
func (db *DB) Close() error {
var errs []error
if db.reader != nil {
if err := db.reader.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close read pool: %w", err))
}
db.reader = nil
}
if db.writer != nil {
if err := db.writer.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close write pool: %w", err))
}
db.writer = nil
}
return errors.Join(errs...)
}
+554
View File
@@ -0,0 +1,554 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// testSettings — числа базы под проверками: те же по смыслу, что и умолчания
// конфига.
func testSettings() Settings {
return Settings{BusyTimeoutMs: 5000, ReadConnections: 4}
}
// newTestDB поднимает базу на пустом каталоге и накатывает схему — ровно тем же
// путём, каким это делает сервис при старте.
func newTestDB(t *testing.T) (*DB, *Store, string) {
t.Helper()
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Logf("не удалось закрыть базу: %v", err)
}
})
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
return db, NewStore(dir), dir
}
// newOwner заводит учётную запись и отдаёт её идентификатор.
func newOwner(t *testing.T, db *DB) string {
t.Helper()
account, _, err := NewUserRepository(db).EnsureUser(contract.Identity{Login: ident.New()})
require.NoError(t, err)
return account.ID
}
// Настройки соединения задаются строкой подключения **обоих** пулов: соблюдение
// внешних ключей в SQLite принадлежит соединению, а не базе, и запрос, сделанный
// один раз после открытия, настроил бы одно соединение из многих.
func TestSettingsApplyToEveryConnection(t *testing.T) {
db, _, _ := newTestDB(t)
var mode string
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode))
assert.Equal(t, "wal", mode, "журнал упреждающей записи выключен")
var busy int
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busy))
assert.Equal(t, testSettings().BusyTimeoutMs, busy, "ожидание занятой базы осталось умолчанием драйвера")
// Читающий пул раздаёт соединения по мере надобности, поэтому спрашиваем
// **несколько** разом: одно настроенное соединение из четырёх — ровно та
// поломка, ради которой настройка уехала в строку подключения.
var wg sync.WaitGroup
answers := make([]int, testSettings().ReadConnections)
start := make(chan struct{})
for i := range answers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
conn, err := db.Reader().Conn(context.Background())
if !assert.NoError(t, err) {
return
}
defer func() { assert.NoError(t, conn.Close()) }()
assert.NoError(t,
conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&answers[i]))
// Соединение придерживается, пока спрашивают остальные: иначе пул
// раздал бы всем одно и то же и правило проверило бы одну настройку
// вместо четырёх.
time.Sleep(10 * time.Millisecond)
}()
}
close(start)
wg.Wait()
for i, answer := range answers {
assert.Equal(t, 1, answer, "соединение %d читающего пула не соблюдает внешние ключи", i)
}
// И держатся внешние ключи **на деле**, а не только настройкой: вставка с
// несуществующим владельцем отвергается обоими пулами.
now := clock.Now().Format(timeLayout)
insert := `INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`
_, err := db.Writer().ExecContext(context.Background(), insert,
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
require.Error(t, err, "пишущее соединение приняло запись с несуществующим владельцем")
_, err = db.Reader().ExecContext(context.Background(), insert,
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
require.Error(t, err, "читающее соединение приняло запись с несуществующим владельцем")
}
// Настройки проверяются на старте: ноль и отрицательное — опечатка, а не режим.
func TestSettingsAreValidated(t *testing.T) {
for name, settings := range map[string]Settings{
"нулевое ожидание": {BusyTimeoutMs: 0, ReadConnections: 4},
"нулевой пул чтения": {BusyTimeoutMs: 5000, ReadConnections: 0},
"отрицательный пул": {BusyTimeoutMs: 5000, ReadConnections: -1},
"отрицательный срок": {BusyTimeoutMs: -1, ReadConnections: 4},
} {
t.Run(name, func(t *testing.T) {
_, err := Open(t.TempDir(), settings)
assert.Error(t, err, "старт на негодном числе прошёл молча")
})
}
}
// Повторный запуск на заведённом каталоге схему второй раз не заводит и прежних
// записей не теряет.
func TestMigrateIsIdempotent(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
owner := newOwner(t, db)
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
var login string
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT provider_login FROM users WHERE id = ?", owner).Scan(&login))
assert.NotEmpty(t, login, "повторный накат потерял прежние строки")
var applied int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version").Scan(&applied))
assert.Equal(t, 2, applied, "шаг отмечен дважды: накат не идемпотентен")
}
// Накат держится исключающей блокировкой каталога данных: второй накат ждёт
// освобождения, а не применяет шаги параллельно.
//
// Библиотека шагов под SQLite блокировки не поставляет вовсе — её запиратели
// объявлены только для PostgreSQL, — поэтому замок наш, и проверка сторожит
// именно его.
func TestMigrationLockSerializesRuns(t *testing.T) {
dir := t.TempDir()
var (
mu sync.Mutex
inside int
overlap bool
)
hold := func() error {
mu.Lock()
inside++
if inside > 1 {
overlap = true
}
mu.Unlock()
time.Sleep(50 * time.Millisecond)
mu.Lock()
inside--
mu.Unlock()
return nil
}
var wg sync.WaitGroup
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
assert.NoError(t, withMigrationLock(dir, hold))
}()
}
wg.Wait()
assert.False(t, overlap, "два наката шли одновременно: замок не держит")
}
// Отказ шага роняет накат и называет шаг: сервис, поднявшийся на неприведённой
// схеме, отвечал бы отказом на каждый запрос.
func TestMigrateFailsLoudly(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
// Таблица уже занята чужой строкой: начальный шаг на такой базе не
// применяется.
_, err = db.Writer().ExecContext(context.Background(), "CREATE TABLE users (id TEXT)")
require.NoError(t, err)
err = Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))
require.Error(t, err, "отказ шага прошёл молча")
assert.Contains(t, err.Error(), "202608220002", "отказ не называет шаг")
// **Шаг и отметка о нём идут одной транзакцией**, поэтому отказавший шаг не
// оставляет за собой ни отметки, ни половины схемы. Полуприменённое
// состояние — то самое, из-за которого следующий запуск применил бы шаг
// второй раз и упал бы на заведённой таблице.
var version int
err = db.Reader().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM goose_db_version WHERE version_id = 202608220002").Scan(&version)
if err == nil {
assert.Equal(t, 0, version, "отказавший шаг отмечен применённым")
}
var tables int
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'audio_records'").Scan(&tables))
assert.Equal(t, 0, tables, "отказавший шаг оставил за собой половину схемы")
}
// Все колонки времени объявлены одним типом и без умолчания: умолчание схемы
// писало бы свой вид времени, а вставка, забывшая проставить время, при нём
// прошла бы молча.
func TestSchemaHasOneTimeShapeWithoutDefaults(t *testing.T) {
db, _, _ := newTestDB(t)
tables := []string{
"users", "files", "topics", "audio_records",
"texts", "structures", "recognitions", "record_events",
}
seen := 0
for _, table := range tables {
rows, err := db.Reader().QueryContext(context.Background(),
"SELECT name, type, dflt_value FROM pragma_table_info(?)", table)
require.NoError(t, err)
for rows.Next() {
var (
name string
columnType string
dflt any
)
require.NoError(t, rows.Scan(&name, &columnType, &dflt))
if !isTimeColumn(name) {
continue
}
seen++
assert.Equal(t, "TEXT", columnType, "колонка %s.%s несёт время не текстом", table, name)
assert.Nil(t, dflt, "у колонки %s.%s есть умолчание времени", table, name)
}
require.NoError(t, rows.Err())
closeRows(t, rows)
}
require.Positive(t, seen, "колонок времени не найдено: правило потеряло предмет")
}
// closeRows закрывает выборку. Отдельной функцией, потому что закрывается она в
// цикле по таблицам: отложенное закрытие копилось бы до конца проверки.
func closeRows(t *testing.T, rows *sql.Rows) {
t.Helper()
require.NoError(t, rows.Close())
}
func isTimeColumn(name string) bool {
return strings.HasSuffix(name, "_at") || name == "delay_time"
}
// Строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в
// отбор захвата одинаково: вид времени в схеме один.
func TestHandwrittenRecordIsAcquiredToo(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
records := NewAudioRecordRepository(db)
byService := &entity.AudioRecord{
Id: ident.New(),
OwnerID: owner,
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
}
require.NoError(t, records.Create(byService))
byHand := ident.New()
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
`INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`,
byHand, owner, entity.StateUploaded, now, now, now,
)
require.NoError(t, err)
acquired := map[string]bool{}
for range 2 {
got, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
acquired[got.ID] = true
}
assert.True(t, acquired[byService.Id], "запись приёма захвату не досталась")
assert.True(t, acquired[byHand], "запись, заведённая запросом к базе, захвату не досталась")
}
// Горячие выборки опираются на индекс: полного сканирования таблицы аудиозаписей
// не показывает ни отбор захвата, ни список, сужаемый владельцем и страницей.
func TestHotQueriesUseIndexes(t *testing.T) {
db, _, _ := newTestDB(t)
acquire := explain(t, db, `
SELECT id FROM audio_records
WHERE state IN (?, ?)
AND halted_at IS NULL
AND (delay_time IS NULL OR delay_time < ?)
AND (acquisition_id IS NULL OR acquire_expires_at IS NULL OR acquire_expires_at < ?)
ORDER BY created_at, id
LIMIT 1`,
entity.StateUploaded, entity.StateNormalized, "now", "now")
list := explain(t, db, `
SELECT id FROM audio_records
WHERE owner_id = ?
AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT 31`,
"owner", "now", "now", "id")
for name, plan := range map[string]string{"отбор захвата": acquire, "список": list} {
assert.NotContains(t, plan, "SCAN audio_records",
"%s идёт полным сканированием таблицы аудиозаписей: %s", name, plan)
assert.Contains(t, plan, "USING", "%s не опирается на индекс: %s", name, plan)
assert.Contains(t, plan, "INDEX", "%s не опирается на индекс: %s", name, plan)
}
}
func explain(t *testing.T, db *DB, query string, args ...any) string {
t.Helper()
rows, err := db.Reader().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
require.NoError(t, err)
defer func() { require.NoError(t, rows.Close()) }()
var plan strings.Builder
for rows.Next() {
var id, parent, notUsed int
var detail string
require.NoError(t, rows.Scan(&id, &parent, &notUsed, &detail))
plan.WriteString(detail)
plan.WriteString("; ")
}
require.NoError(t, rows.Err())
return plan.String()
}
// Мягкая остановка закрывает то же, что открыл подъём, и повторная остановка не
// даёт паники.
func TestCloseIsIdempotent(t *testing.T) {
db, err := Open(t.TempDir(), testSettings())
require.NoError(t, err)
require.NoError(t, db.Close())
assert.NoError(t, db.Close(), "повторное закрытие отказало")
}
// Укладка атомарна: источник, отдавший отказ на середине потока, не оставляет ни
// файла под рабочим именем, ни временного имени в подкаталоге записи.
func TestStorePutIsAtomic(t *testing.T) {
_, store, dir := newTestDB(t)
recordID := ident.New()
_, err := store.Put(recordID, "voice.mp3", &brokenReader{})
require.Error(t, err, "отказ источника прошёл молча")
_, err = os.Stat(filepath.Join(dir, recordsDir, recordID, "voice.mp3"))
assert.True(t, os.IsNotExist(err), "рабочее имя появилось при оборванном потоке")
temporary, err := store.HasTemporary(recordID)
require.NoError(t, err)
assert.False(t, temporary, "временное имя осталось в подкаталоге записи")
}
// brokenReader отдаёт часть потока и обрывается — так выглядит отправитель,
// закрывший соединение на середине.
type brokenReader struct {
sent bool
}
func (r *brokenReader) Read(p []byte) (int, error) {
if !r.sent {
r.sent = true
copy(p, strings.Repeat("a", min(len(p), 64)))
return min(len(p), 64), nil
}
return 0, errors.New("источник оборвался")
}
// Отказ укладки несёт причину и не несёт пути.
//
// Обе половины — одно требование, и порознь они друг друга отменяют. Причина
// нужна владельцу: исчерпание места, отсутствие прав и негодная раскладка
// каталога требуют трёх разных действий, а отказ укладки — единственная
// поверхность, на которой он их видит. Путь не нужен: он ведёт внутрь каталога
// данных, а отказ кончается в журнале, откуда строку потом не убрать.
func TestStoreFailureCarriesCauseWithoutPath(t *testing.T) {
_, store, dir := newTestDB(t)
// noPath судит вторую половину: ни каталога данных, ни временной приставки
// в цепочке отказа быть не должно.
noPath := func(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.NotContains(t, err.Error(), dir, "путь внутри каталога данных уехал в отказ")
assert.NotContains(t, err.Error(), tempPrefix, "временное имя укладки уехало в отказ")
}
t.Run("места на диске нет", func(t *testing.T) {
recordID := ident.New()
_, err := store.Put(recordID, "voice.mp3", &diskFullReader{
path: filepath.Join(dir, recordsDir, recordID, tempPrefix+"whatever"),
})
noPath(t, err)
assert.ErrorIs(t, err, syscall.ENOSPC, "причина отказа отброшена: место на диске неотличимо от прочего")
})
t.Run("прав на подкаталог записи нет", func(t *testing.T) {
recordID := ident.New()
recordDir := filepath.Join(dir, recordsDir, recordID)
require.NoError(t, os.MkdirAll(recordDir, 0o750))
require.NoError(t, os.Chmod(recordDir, 0o500))
t.Cleanup(func() {
if err := os.Chmod(recordDir, 0o750); err != nil {
t.Logf("не удалось вернуть права подкаталогу записи: %v", err)
}
})
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
noPath(t, err)
assert.ErrorIs(t, err, fs.ErrPermission, "причина отказа отброшена: отсутствие прав неотличимо от прочего")
})
t.Run("подкаталогом записи занято не то", func(t *testing.T) {
recordID := ident.New()
require.NoError(t, os.MkdirAll(filepath.Join(dir, recordsDir), 0o750))
require.NoError(t, os.WriteFile(filepath.Join(dir, recordsDir, recordID), []byte("не каталог"), 0o600))
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
noPath(t, err)
assert.ErrorIs(t, err, syscall.ENOTDIR, "причина отказа отброшена: негодная раскладка неотличима от прочего")
})
t.Run("копии нет", func(t *testing.T) {
_, err := store.Open(ident.New(), "voice.mp3")
noPath(t, err)
assert.ErrorIs(t, err, fs.ErrNotExist, "причина отказа отброшена: «файла нет» неотличимо от прочего")
})
}
// diskFullReader отказывает так, как отказывает диск: причина приходит обёрткой
// пакета `os`, и путь лежит в ней. Настоящим источником укладки служит `*os.File`
// рабочей копии, и его отказ приходит ровно этой формой.
type diskFullReader struct {
path string
}
func (r *diskFullReader) Read([]byte) (int, error) {
return 0, &os.PathError{Op: "write", Path: r.path, Err: syscall.ENOSPC}
}
// Копии одной записи лежат вместе — под её идентификатором, — и второго места,
// где лежит что-то из них, нет.
func TestCopiesOfRecordLiveTogether(t *testing.T) {
db, store, dir := newTestDB(t)
owner := newOwner(t, db)
files := NewFileRepository(db, store)
recordID := ident.New()
for _, name := range []string{"original.mp3", "normalized.ogg"} {
work, err := files.Stage(filepath.Ext(name), strings.NewReader("содержимое "+name))
require.NoError(t, err)
_, err = files.Create(recordID, name, work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
require.NoError(t, work.Close())
}
entries, err := os.ReadDir(filepath.Join(dir, recordsDir, recordID))
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
assert.ElementsMatch(t, []string{"original.mp3", "normalized.ogg"}, names)
records, err := os.ReadDir(filepath.Join(dir, recordsDir))
require.NoError(t, err)
assert.Len(t, records, 1, "второго места для копий записи не появляется")
}
// Содержимое читается потоком с перемоткой: отдача по диапазону берёт кусок, а
// не файл целиком.
func TestOpenGivesSeekableStream(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
files := NewFileRepository(db, store)
recordID := ident.New()
work, err := files.Stage(".mp3", strings.NewReader("0123456789"))
require.NoError(t, err)
file, err := files.Create(recordID, "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
require.NoError(t, work.Close())
reader, err := files.Open(file.Id)
require.NoError(t, err)
defer func() { require.NoError(t, reader.Close()) }()
_, err = reader.Seek(4, io.SeekStart)
require.NoError(t, err)
slice := make([]byte, 3)
_, err = io.ReadFull(reader, slice)
require.NoError(t, err)
assert.Equal(t, "456", string(slice))
}
+215 -34
View File
@@ -1,56 +1,237 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/doug-martin/goqu/v9"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге системы,
// а не в каталоге данных: последний смонтирован на сервере, и временному там не
// место.
type workFile struct {
path string
}
func (w *workFile) Path() string { return w.path }
func (w *workFile) Size() (int64, error) {
info, err := os.Stat(w.path)
if err != nil {
return 0, fmt.Errorf("failed to stat work file: %w", err)
}
return info.Size(), nil
}
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove work file: %w", err)
}
return nil
}
// FileRepository — копии записей: строка в базе и содержимое в каталоге данных.
type FileRepository struct {
db *sql.DB
gq *goqu.Database
db *DB
store *Store
}
func NewFileRepository(conn *sql.DB, gq *goqu.Database) *FileRepository {
return &FileRepository{conn, gq}
func NewFileRepository(db *DB, store *Store) *FileRepository {
return &FileRepository{db: db, store: store}
}
func (repo *FileRepository) Create(file *entity.File) error {
record := goqu.Record{
"id": file.Id,
"storage": file.Storage,
"file_name": file.FileName,
"size": file.Size,
"created_at": file.CreatedAt,
}
query := repo.gq.Insert("files").Rows(record)
sql, args, err := query.ToSQL()
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
f, err := os.CreateTemp("", "transcriber-*"+ext)
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
return nil, fmt.Errorf("failed to create work file: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("failed to close work file: %w", err)
}
return &workFile{path: path}, nil
}
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
return newWorkFile(ext)
}
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
work, err := newWorkFile(ext)
if err != nil {
return nil, err
}
_, err = repo.db.Exec(sql, args...)
if err := writeTo(work.path, content); err != nil {
// Отказ уборки не подменяет отказ записи, но и не теряется.
return nil, errors.Join(err, work.Close())
}
return work, nil
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
file, err := repo.GetByID(fileID)
if err != nil {
return fmt.Errorf("failed to insert file: %w", err)
return nil, err
}
work, err := newWorkFile(filepath.Ext(file.FileName))
if err != nil {
return nil, err
}
src, err := repo.store.Open(file.RecordID, file.FileName)
if err != nil {
return nil, errors.Join(err, work.Close())
}
defer func() { _ = src.Close() }()
if err := writeTo(work.path, src); err != nil {
return nil, errors.Join(err, work.Close())
}
return work, nil
}
// Create кладёт рабочую копию в каталог данных и заводит строку о файле.
//
// Порядок один: строка заводится **после** того, как содержимое лежит целиком
// под рабочим именем. Обратный порядок оставлял бы в базе строку, указывающую на
// файл, которого ещё нет или который короче принятого.
//
// Отсюда и уборка: содержимое легло, а строка не сохранилась — уложенный файл
// убирается, и следа от него не остаётся. Файл, переживший свою строку, —
// штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
// принятой это мусор, на который не ссылается ничто и о котором узнать неоткуда.
//
// Владелец обязателен и лежит своей колонкой: пустой отвергает схема — колонка
// объявлена связью с учётной записью, и пустое значение ей не отвечает.
func (repo *FileRepository) Create(
recordID, name string,
work contract.WorkFile,
meta contract.FileMeta,
ownerID string,
) (*entity.File, error) {
source, err := os.Open(work.Path())
if err != nil {
// Причина сохраняется, путь снимается: он ведёт к рабочей копии чужого
// аудио, а отказ кончается в журнале.
return nil, fmt.Errorf("failed to read work file: %w", causeOf(err))
}
size, putErr := repo.store.Put(recordID, name, source)
closeErr := source.Close()
if err := errors.Join(putErr, closeErr); err != nil {
return nil, err
}
file := &entity.File{
Id: ident.New(),
RecordID: recordID,
FileName: name,
Size: size,
Format: meta.Format,
DurationMs: meta.DurationMs,
CreatedAt: clock.Now(),
}
query, args := insertSQL("files", map[string]any{
"id": file.Id,
"owner_id": ownerID,
"record_id": file.RecordID,
"file_name": file.FileName,
"size_bytes": file.Size,
"format": file.Format,
"duration_ms": file.DurationMs,
"created_at": formatTime(file.CreatedAt),
})
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
// Уложенное содержимое убирается: строки о нём не будет, и ссылаться на
// него нечему. Имя файла в отказ не идёт — оно часть пути к чужому аудио.
return nil, errors.Join(
fmt.Errorf("failed to store the file row of record %s: %w", recordID, err),
repo.store.Remove(recordID, name),
)
}
return file, nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
file := &entity.File{}
var (
createdAt string
recordID string
fileName string
size int64
format string
durationMs int64
)
err := repo.db.Reader().QueryRowContext(context.Background(),
`SELECT record_id, file_name, size_bytes, format, duration_ms, created_at
FROM files WHERE id = ?`, id,
).Scan(&recordID, &fileName, &size, &format, &durationMs, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("file %s is not found", id)
}
return nil, fmt.Errorf("failed to get file %s: %w", id, err)
}
file.Id = id
file.RecordID = recordID
file.FileName = fileName
file.Size = size
file.Format = format
file.DurationMs = durationMs
file.CreatedAt = requiredTimeOf(createdAt)
return file, nil
}
// Open отдаёт содержимое хранимой копии потоком с перемоткой: отдача по
// диапазону читает кусок, а не файл целиком.
func (repo *FileRepository) Open(fileID string) (io.ReadSeekCloser, error) {
file, err := repo.GetByID(fileID)
if err != nil {
return nil, err
}
return repo.store.Open(file.RecordID, file.FileName)
}
// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to open work file: %w", err)
}
if _, err := io.Copy(dst, content); err != nil {
_ = dst.Close()
return fmt.Errorf("failed to write work file: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to close work file: %w", err)
}
return nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
query := repo.gq.From("files").Select("id", "storage", "file_name", "size", "created_at").Where(goqu.C("id").Eq(id))
sql, args, err := query.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var file entity.File
err = repo.db.QueryRow(sql, args...).Scan(&file.Id, &file.Storage, &file.FileName, &file.Size, &file.CreatedAt)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return &file, nil
}
+159
View File
@@ -0,0 +1,159 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// UserRepository — учётные записи сервиса.
type UserRepository struct {
db *DB
}
func NewUserRepository(db *DB) *UserRepository {
return &UserRepository{db: db}
}
// EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит
// её.
//
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
// представиться — личные токены — возьмёт этот же метод; правило, уложенное
// куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском,
// либо вытаскивать задним числом.
//
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
// его работы.
//
// **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда,
// когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому
// пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку —
// опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы
// в очередь к единственному пишущему соединению. Ждать там нечего: заводится
// учётная запись один раз за жизнь человека.
//
// **Окно между двумя соединениями закрыто повторным поиском внутри
// транзакции.** Между поиском читающим пулом и открытием пишущей транзакции
// запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а
// уникальность ключа держит схема — не порядок обращений.
func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) {
login, ok := entity.AcceptProviderLogin(identity.Login)
if !ok {
return nil, false, contract.ErrLoginNotAcceptable
}
account, err := findUserByLogin(repo.db.Reader(), login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, nil
}
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Повторный поиск закрывает окно между читающим пулом и пишущей
// транзакцией: пока её ждали, запись мог завести сосед.
account, err = findUserByLogin(tx, login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, commitAccount(tx, account)
}
name := entity.AcceptDisplayName(identity.Name)
email, _ := entity.AcceptEmail(identity.Email)
account, err = insertUser(tx, login, name, email)
switch {
case err == nil:
return account, true, commitAccount(tx, account)
case !isUniqueViolation(err):
return nil, false, fmt.Errorf("failed to create user account: %w", err)
}
// **Два отказа уникальности различаются, и исход у них разный**, а какая
// колонка не сошлась, код отказа не называет. Различает их повторный поиск
// по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо
// просто взять заведённую соседом запись.
account, err = findUserByLogin(tx, login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, commitAccount(tx, account)
}
// Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от
// провайдера, занят другой учётной записью (общий ящик, семья, группа).
// Запись заводится **без почты**: она необязательна и ключом не служит. Без
// этого разреза второй человек с общим адресом не завёлся бы никогда —
// повторный поиск по логину снова ничего не находит.
account, err = insertUser(tx, login, name, "")
if err != nil {
return nil, false, fmt.Errorf("failed to create user account without email: %w", err)
}
return account, true, commitAccount(tx, account)
}
func commitAccount(tx *sql.Tx, account *contract.UserAccount) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err)
}
return nil
}
func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) {
id := ident.New()
now := formatTime(clock.Now())
_, err := tx.ExecContext(context.Background(),
`INSERT INTO users (id, provider_login, name, email, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, login, name, email, now, now,
)
if err != nil {
return nil, err
}
return &contract.UserAccount{ID: id, Name: name}, nil
}
// rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и
// у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним
// запросом: второй его копией они разошлись бы молча.
type rowQuerier interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// findUserByLogin ищет учётную запись по ключу. Значение уходит базе
// **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и
// подставленная в текст она правила бы сам запрос, а не только его аргумент.
func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) {
account := &contract.UserAccount{}
err := q.QueryRowContext(context.Background(),
"SELECT id, name FROM users WHERE provider_login = ?", login,
).Scan(&account.ID, &account.Name)
switch {
case err == nil:
return account, nil
case errors.Is(err, sql.ErrNoRows):
return nil, nil
default:
return nil, fmt.Errorf("failed to look up user account: %w", err)
}
}
+110
View File
@@ -0,0 +1,110 @@
package sqlite
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"syscall"
"github.com/pressly/goose/v3"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite/migrations"
)
// migrationLockFile — файл, на котором берётся замок наката. Лежит в каталоге
// данных рядом с базой: замок принадлежит каталогу, а не машине.
const migrationLockFile = "migrate.lock"
// Migrate приводит схему к последнему шагу.
//
// # Порядок
//
// Накат идёт **до подъёма входов и до старта воркеров**, а его отказ роняет
// старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом на каждый
// запрос и на каждый прогон воркера — вместо одной строки о причине их
// становятся сотни, и первопричина в них теряется.
//
// # Чем держится неделимость
//
// Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
// соединении и внутри выполняет и сам шаг, и вставку версии в таблицу учёта.
// Отменяет это только пометка `NO TRANSACTION` у самого шага, и мы её не ставим.
//
// Порядок шагов детерминирован и выводится из версии шага, а не из порядка
// чтения каталога: собранные шаги сортируются по версии, а две одинаковых версии
// дают отказ сбора, а не молчаливый выбор одного.
//
// # Почему замок наш
//
// Исключающей блокировки наката библиотека под SQLite не даёт вовсе: её
// запиратели объявлены только для PostgreSQL, а провайдер без запирателя
// накатывает без всякой блокировки. Замок поэтому берём сами — на файле в
// каталоге данных. С умершим процессом его снимает ядро, поэтому просроченного
// замка, который надо чистить руками, не остаётся.
//
// Накат идёт по **пишущему** соединению: он читает таблицу учёта и следом в неё
// пишет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
// повышает.
func Migrate(ctx context.Context, db *DB, dataDir string, logger *slog.Logger) error {
if logger == nil {
logger = slog.Default()
}
provider, err := goose.NewProvider(
goose.DialectSQLite3,
db.Writer(),
nil,
goose.WithGoMigrations(migrations.All()...),
// Глобальный список библиотеки не читается: перечень шагов приходит
// доводом, и два провайдера в одном процессе за общее состояние не
// спорят.
goose.WithDisableGlobalRegistry(true),
)
if err != nil {
return fmt.Errorf("failed to prepare schema migrations: %w", err)
}
return withMigrationLock(dataDir, func() error {
results, err := provider.Up(ctx)
if err != nil {
// Отказ называет шаг: библиотека кладёт версию в текст отказа, и
// владелец сервиса по ней находит файл шага.
return fmt.Errorf("failed to apply schema migration: %w", err)
}
for _, result := range results {
logger.Info("Schema migration applied",
"migration_version", result.Source.Version,
"duration_ms", result.Duration.Milliseconds())
}
return nil
})
}
// withMigrationLock берёт исключающий замок каталога данных на всё время наката.
//
// Замок блокирующий: второй процесс, поднятый на том же каталоге, ждёт его
// освобождения, а не применяет шаги параллельно. Два наката, разошедшихся на
// одном шаге, оставили бы схему в состоянии, которого не описывает ни один шаг.
func withMigrationLock(dataDir string, run func() error) error {
path := filepath.Join(dataDir, migrationLockFile)
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o640)
if err != nil {
return fmt.Errorf("failed to open migration lock: %w", err)
}
// Замок снимается **закрытием дескриптора**, и отдельного снятия не нужно:
// он принадлежит открытому файлу, а не процессу. С умершим процессом его
// снимает ядро тем же движением — просроченного замка, который надо чистить
// руками, не остаётся.
defer func() { _ = file.Close() }()
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
return fmt.Errorf("failed to lock the data directory for migration: %w", err)
}
return run()
}
@@ -0,0 +1,254 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"strconv"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// up202608220002 заводит схему сервиса целиком.
//
// Шаг один, и он начальный: прежние шаги встроенного хранилища удалены вместе с
// ним — разовое снятие инварианта «применённая миграция не переписывается»
// решением владельца от 2026-08-22. Причина названа прямо: стадия проекта —
// стройка, на сервере данных нет, сервис остановлен, а новая база ведёт учёт
// применённого своей таблицей, которой отметки прежнего каталога не годятся
// вовсе. Снятие кончается этим шагом: уехав на сервер, он подпадает под
// инвариант как всякий прежний.
//
// Порядок заведения задан связями: сперва учётные записи, потом всё, что на них
// ссылается, и только потом обратные ссылки записи на её приложения.
//
// **Времени умолчанием схема не ставит.** Вид времени один на все колонки —
// `TEXT` в RFC 3339, UTC, секундная точность, — и ставит его приложение единой
// точкой. `CURRENT_TIMESTAMP` писал бы свой вид, отличный от объявленного, а
// вставка, забывшая проставить время, при умолчании прошла бы молча.
//
// **Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки
// и вид текста были закрыты схемой, потому что панель владельца правила запись
// руками и вправе была завести значение, которого сервис не знает. Панели нет,
// правка идёт только нашим кодом, и `CHECK` остался бы ценой — новое значение
// стоило бы нового шага схемы — без покупателя.
func up202608220002(ctx context.Context, tx *sql.Tx) error {
for _, statement := range initStatements() {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("failed to apply initial schema: %w", err)
}
}
return nil
}
// down202608220002 сносит схему целиком. Порядок обратный порядку заведения:
// приложения ссылаются на запись, запись — на учётную запись.
func down202608220002(ctx context.Context, tx *sql.Tx) error {
tables := []string{
"record_events",
"recognitions",
"structures",
"texts",
"record_topics",
"audio_records",
"topics",
"files",
"users",
}
for _, table := range tables {
if _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table); err != nil {
return fmt.Errorf("failed to drop %s: %w", table, err)
}
}
return nil
}
// initStatements — шаг по одному оператору на элемент.
//
// Россыпью, а не одной строкой с разделителями: тело триггера само несёт точку с
// запятой, и разбиение общей строки резало бы его пополам.
func initStatements() []string {
return []string{
// Учётная запись. Ключ — логин у провайдера: его приносит заголовок
// доверенного источника, и по нему запись находится при каждом
// обращении. Адрес почты необязателен и ключом не служит — он меняется,
// и первое обращение с чужим адресом досталось бы чужой записи.
`CREATE TABLE users (
id TEXT NOT NULL PRIMARY KEY,
provider_login TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_users_provider_login ON users (provider_login)`,
// Уникальность почты частичная: пустая почта законна и не спорит с
// другой пустой. Индекс нужен затем, чтобы занятый адрес отвергался
// схемой — по этому отказу заведение переходит на ветвь «запись без
// почты», а не отдаёт чужую учётную запись.
`CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email <> ''`,
// Копия записи на диске. Владелец лежит своей колонкой, а не выводится
// через запись: файл переживает свою запись — шаг заводит его до
// сохранения, — и заведённый до неё остаётся с владельцем и без ссылки.
//
// Ссылки на аудиозапись внешним ключом нет намеренно, и `record_id`
// здесь — имя подкаталога, где копия лежит. Приём заводит файл **до**
// самой записи, и обязательная связь отвергала бы первую же принятую
// запись.
`CREATE TABLE files (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
record_id TEXT NOT NULL,
file_name TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
format TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX idx_files_owner ON files (owner_id)`,
// Словарь тем. Своя таблица, а не набор строк в записи: перечень тем
// человека нужен целиком перед каждым обращением к модели, а собрать его
// из наборов строк можно только перебором всех его записей.
`CREATE TABLE topics (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_topics_owner_name ON topics (owner_id, name)`,
// Аудиозапись — центральная сущность. Поля очереди соседствуют с
// доменом, но не с содержимым: расшифровка лежит строкой `texts`, и
// чтение очереди её не тянет.
//
// Колонка владельца обязательна и объявлена внешним ключом: ничьей
// записи не бывает, и держит это схема, а не проверка вызывающего.
// Пустое значение внешнему ключу не отвечает — идентификаторы у учётных
// записей непустые, — поэтому ничью запись отвергает та же связь.
//
// `duration_ms` и `size_bytes` обязательны и различать «неизвестно» и
// «ноль» не обязаны: обе величины ставит приём и ставит всегда — запись,
// метаданные которой прочитать не удалось, отвергается отказом и не
// заводится вовсе. Решение владельца 2026-08-15.
`CREATE TABLE audio_records (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
title TEXT,
brief TEXT,
original_filename TEXT,
duration_ms INTEGER NOT NULL,
size_bytes INTEGER NOT NULL,
state TEXT NOT NULL,
state_entered_at TEXT NOT NULL,
halted_at TEXT,
halt_reason TEXT,
error_text TEXT,
acquisition_id TEXT,
acquire_expires_at TEXT,
delay_time TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
original_file_id TEXT REFERENCES files (id),
normalized_file_id TEXT REFERENCES files (id),
transcript_text_id TEXT,
literary_text_id TEXT,
structure_id TEXT,
recognition_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
// Отбор захвата идёт по рубежу, признаку остановки и порядку ленты.
// Индекс заводится здесь, а не потом: применённый шаг схемы не
// переписывается, и добавление индекса стоило бы отдельного шага.
`CREATE INDEX idx_audio_records_acquire
ON audio_records (state, halted_at, created_at, id)`,
// Страница списка сужается владельцем и режется полным ключом
// сортировки — парой «время заведения и ключ записи».
`CREATE INDEX idx_audio_records_owner_page
ON audio_records (owner_id, created_at, id)`,
// Темы записи. Отдельной таблицей связи, а не колонкой-перечнем: у
// набора строк в колонке нет ни связи, ни потолка.
`CREATE TABLE record_topics (
record_id TEXT NOT NULL REFERENCES audio_records (id),
topic_id TEXT NOT NULL REFERENCES topics (id),
PRIMARY KEY (record_id, topic_id)
)`,
`CREATE INDEX idx_record_topics_topic ON record_topics (topic_id)`,
// Потолок числа тем держит схема: без него часовой разговор даёт два
// десятка тем, и словарь распухает за неделю. Число берётся у домена —
// то же самое, которое сервис объявляет приложению.
`CREATE TRIGGER trg_record_topics_limit
BEFORE INSERT ON record_topics
BEGIN
SELECT RAISE(ABORT, 'record has too many topics')
WHERE (
SELECT COUNT(*) FROM record_topics WHERE record_id = NEW.record_id
) >= ` + strconv.Itoa(entity.MaxTopicsPerRecord) + `;
END`,
// Тексты записи. Пара «запись и вид» уникальна: повтор прерванного шага
// иначе завёл бы второй комплект строк, и вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
`CREATE TABLE texts (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
kind TEXT NOT NULL,
contents TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_texts_record_kind ON texts (record_id, kind)`,
// Структура реплик. Номер версии нужен потому, что разбор сохранённого
// ответа изменится раньше, чем архив пересчитают.
`CREATE TABLE structures (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
version INTEGER NOT NULL,
contents TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_structures_record_version ON structures (record_id, version)`,
// Попытка распознавания у внешнего провайдера.
//
// Сохранённый ответ лежит **третьим файлом в подкаталоге записи**, а
// здесь стоит только его имя: шаг опроса читает эту строку раз в
// несколько секунд, и ответ на многочасовую запись, положенный колонкой,
// ехал бы в память при каждом опросе.
`CREATE TABLE recognitions (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
provider TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
source_uri TEXT NOT NULL DEFAULT '',
payload_file TEXT NOT NULL DEFAULT '',
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX idx_recognitions_record ON recognitions (record_id)`,
// Журнал событий записи. Колонка текста отказа зовётся `outcome_text`, а
// не `error_text`: последнее имя названо поимённо инвариантом проекта о
// секрете, и две колонки с этим именем сделали бы инвариант
// двусмысленным.
`CREATE TABLE record_events (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
origin TEXT NOT NULL,
step TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL,
outcome_text TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX idx_record_events_record ON record_events (record_id)`,
}
}
@@ -0,0 +1,36 @@
// Package migrations — шаги схемы базы.
//
// Шаг лежит своим файлом, имя файла начинается версией, и **применённый шаг не
// переписывается** — только новым файлом. Инвариант проекта держится так же, как
// держался прежде: изменение схемы это новый шаг, а не правка уехавшего.
//
// Шаги лежат отдельным каталогом, а не файлом внутри пакета хранилища, по
// внешней причине: сверка документов ловит изменённый шаг схемы при нетронутом
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
// `[docs]`), а префикс наводится только на каталог.
//
// Регистрация идёт **перечнем**, а не глобальным списком библиотеки: провайдер
// заводится в точке входа и получает этот перечень доводом, поэтому два
// провайдера в одном процессе — например, сервис и проверка — не спорят за общее
// состояние.
package migrations
import (
"github.com/pressly/goose/v3"
)
// All — шаги схемы в порядке версий.
//
// Порядок исхода от порядка этого перечня не зависит: библиотека сортирует шаги
// по версии сама. Перечень собран ради того, чтобы шаг, добавленный файлом и
// забытый здесь, не оказался незамеченным: незарегистрированный шаг не
// накатывается вовсе.
func All() []*goose.Migration {
return []*goose.Migration{
goose.NewGoMigration(
202608220002,
&goose.GoFunc{RunTx: up202608220002},
&goose.GoFunc{RunTx: down202608220002},
),
}
}
@@ -0,0 +1,163 @@
package sqlite
import (
"context"
"errors"
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// payloadSuffix — окончание имени файла, под которым лежит сохранённый ответ
// провайдера. Имя задаёт сервис, как и у копий аудио.
const payloadSuffix = ".payload"
type RecognitionRepository struct {
db *DB
store *Store
}
func NewRecognitionRepository(db *DB, store *Store) *RecognitionRepository {
return &RecognitionRepository{db: db, store: store}
}
// Create заводит строку попытки **до** обращения к провайдеру.
//
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
started := clock.Now()
if r.Id == "" {
r.Id = ident.New()
}
now := formatTime(started)
_, err := repo.db.Writer().ExecContext(context.Background(),
`INSERT INTO recognitions
(id, record_id, provider, model, external_id, source_uri, started_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.Id, r.RecordID, r.Provider, r.Model, r.ExternalID, r.SourceURI, now, now, now,
)
if err != nil {
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
}
r.StartedAt = &started
return nil
}
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
// наружу не платит.
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
_, err := repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET source_uri = ?, external_id = ?, updated_at = ? WHERE id = ?",
sourceURI, externalID, formatTime(clock.Now()), id,
)
if err != nil {
return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err)
}
return nil
}
// Finish кладёт сохранённый ответ провайдера **третьим файлом в подкаталоге
// записи** и отмечает завершение попытки.
//
// Файлом, а не колонкой: шаг опроса читает эту строку раз в несколько секунд, и
// ответ на многочасовую запись, положенный колонкой, ехал бы в память при каждом
// опросе. Хранится он потому, что результат операции у провайдера не
// переспрашивается.
//
// Пустой ответ поверх сохранённого не кладётся — тем же доводом, что и у текста:
// повторный опрос вправе вернуть пустое, и безусловная замена стёрла бы
// сохранённое без возврата.
func (repo *RecognitionRepository) Finish(id string, raw []byte) error {
attempt, err := repo.GetByID(id)
if err != nil {
return err
}
name := id + payloadSuffix
if len(raw) > 0 {
if _, err := repo.store.Put(attempt.RecordID, name, bytesReader(raw)); err != nil {
// Путь к сохранённому ответу наружу не идёт: отказ называет попытку
// её идентификатором.
return errors.Join(fmt.Errorf("failed to store provider payload of attempt %s", id), err)
}
}
finished := formatTime(clock.Now())
if len(raw) > 0 {
_, err = repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET payload_file = ?, finished_at = ?, updated_at = ? WHERE id = ?",
name, finished, finished, id,
)
} else {
_, err = repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET finished_at = ?, updated_at = ? WHERE id = ?",
finished, finished, id,
)
}
if err != nil {
return fmt.Errorf("failed to store provider payload of attempt %s", id)
}
return nil
}
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
attempt := &entity.Recognition{Id: id}
var startedAt, finishedAt, payloadFile nullString
err := repo.db.Reader().QueryRowContext(context.Background(),
`SELECT record_id, provider, model, external_id, source_uri, payload_file, started_at, finished_at
FROM recognitions WHERE id = ?`, id,
).Scan(
&attempt.RecordID, &attempt.Provider, &attempt.Model,
&attempt.ExternalID, &attempt.SourceURI, &payloadFile,
&startedAt, &finishedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
attempt.StartedAt = timeOf(startedAt.NullString)
attempt.FinishedAt = timeOf(finishedAt.NullString)
return attempt, nil
}
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
// нужен: шаг опроса читает строку попытки без него.
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
attempt, err := repo.GetByID(id)
if err != nil {
return nil, err
}
var payloadFile string
if err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT payload_file FROM recognitions WHERE id = ?", id,
).Scan(&payloadFile); err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
if payloadFile == "" {
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
}
file, err := repo.store.Open(attempt.RecordID, payloadFile)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
raw, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read stored payload of attempt %s", id)
}
return raw, nil
}
@@ -0,0 +1,62 @@
package sqlite
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// nullString — обёртка ради читаемости выборок: колонка, допускающая пустое
// значение, читается в неё, а домену отдаётся указателем.
type nullString struct {
sql.NullString
}
// bytesReader отдаёт содержимое в памяти потоком: сохранённый ответ провайдера
// приходит целиком байтами, а укладка принимает поток.
func bytesReader(raw []byte) io.Reader {
return bytes.NewReader(raw)
}
type RecordEventRepository struct {
db *DB
}
func NewRecordEventRepository(db *DB) *RecordEventRepository {
return &RecordEventRepository{db: db}
}
// Append пишет строку журнала событий записи.
//
// Журнал пишется на смену рубежа, на остановку и на возврат в работу, а не на
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
// молча.
//
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
// наравне с журналом сервиса.
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
if event.Id == "" {
event.Id = ident.New()
}
_, err := repo.db.Writer().ExecContext(context.Background(),
`INSERT INTO record_events
(id, record_id, origin, step, outcome, outcome_text, duration_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
event.Id, event.RecordID, event.Origin, event.Step,
event.Outcome, event.OutcomeText, event.DurationMs, formatTime(clock.Now()),
)
if err != nil {
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More