Compare commits

...
25 Commits
Author SHA1 Message Date
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
129 changed files with 3496 additions and 1795 deletions
+13
View File
@@ -0,0 +1,13 @@
# Раскладка av-dev в этом проекте: версия и настройки проверок.
# Файл ведут скиллы плагина, править руками можно — комментарии свои.
version = 4 # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»
[docs]
# каталог миграций: по нему docs.py сверяет схему с database.md
migrations = "internal/adapter/repo/pocketbase/migrations"
[tasks]
# каталог задач от корня репозитория; имена частей — умолчания скрипта
dir = "tasks"
stage = "build"
+6
View File
@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"av-dev@av-dev-skills": true,
"av-dev-git@av-dev-skills": true
}
}
+42 -10
View File
@@ -97,8 +97,8 @@ task gate # весь набор проверок разом
``` ```
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml`
скопируй `config.dist.toml` и заполни; известные прорехи образца перечислены в скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
[docs/conventions/config.md](docs/conventions/config.md) строками в [docs/conventions/config.md](docs/conventions/config.md) строками
«*Расхождение:*». «*Расхождение:*».
## Гейт ## Гейт
@@ -141,7 +141,7 @@ task gate # весь набор проверок разом
**затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в **затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в
коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`, коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`,
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком, `gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
сверка версий Go, три сверки документов и `govulncheck`: они смотрят всё сверка версий Go, сверки документов и `govulncheck`: они смотрят всё
дерево либо требуют сети, а pre-commit обязан быть быстрым. дерево либо требуют сети, а pre-commit обязан быть быстрым.
- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на - **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
@@ -155,14 +155,14 @@ task gate # весь набор проверок разом
которого образ перестаёт собираться, но собираемости не проверяет. Собрать которого образ перестаёт собираться, но собираемости не проверяет. Собрать
образ по-прежнему может только человек — `task image`, и на подъёме версии образ по-прежнему может только человек — `task image`, и на подъёме версии
это обязательно; это обязательно;
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и два его - собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и часть его
правила подавлены поимённо — `DL3007` до задачи `pin-runtime-image-base` и правил подавлена поимённо — `DL3007` до задачи `pin-runtime-image-base` и
`DL3018` по существу (alpine не держит старые версии пакетов, закрепление `DL3018` по существу (alpine не держит старые версии пакетов, закрепление
ломает сборку через недели). Причины стоят строками в `Taskfile.yml`; ломает сборку через недели). Причины стоят строками в `Taskfile.yml`;
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс - `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
коммита. Полную историю никто не проверяет; коммита. Полную историю никто не проверяет;
- согласованность документов между собой и с кодом — её судят агенты, зовёт - согласованность документов между собой и с кодом — её судят агенты, зовёт
их скилл `av-dev-docs:healthcheck`, и звать его надо руками; их скилл `av-dev:doc-healthcheck`, и звать его надо руками;
- покрытие изменённых строк не считается ничем. - покрытие изменённых строк не считается ничем.
**Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.** **Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.**
@@ -186,12 +186,29 @@ task gate # весь набор проверок разом
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его (`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
ронять и пересоздавать можно свободно. ронять и пересоздавать можно свободно.
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном - **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
перехватывает обновления у работающего, и пользователь теряет ответы. перехватывает обновления у работающего, и пользователь теряет ответы. Запускай
с `telegram.enabled = false`: сервис поднимается без Telegram, к нему не уходит
ни одного обращения, и работает он одним входом, по HTTP. Пустого
`bot_token` для этого мало и больше не значит ничего: включён вход или нет,
решает отдельный признак `telegram.enabled`, а пустой ключ при `enabled = true`
роняет старт. Выключенного входа
для подъёма тоже мало: секции `[auth]` и `[yandex]` проверяются на старте, но
наружу при этом не ходят, так что годятся выдуманные непустые значения;
подробности строками в `config.example.toml`.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`. подставляй `internal/adapter/recognizer/memory.go`.
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server` - **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
запускает человек. запускает человек.
- **Проверок над проверками не заводить.** Уровень проверки один: линтеры и
тесты судят код сервиса, а судить их самих незачем. Под запрет попадают тесты
на шаги гейта и на свои скрипты проверок, стражи предмета у правил,
механизация покрытия изменённого кода, мутационная сверка оракулов и
требование мутировать тест, чтобы убедиться в его способности упасть. Решение
владельца 2026-08-13; им закрыты четыре задачи — причины и даты в
[tasks/REJECTED.md](tasks/REJECTED.md), — и тем же решением снесены двадцать
сценариев шага сверки версий Go, единственный такой файл в проекте.
Исключений у запрета нет.
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во - **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
временном каталоге и убирают за собой. временном каталоге и убирают за собой.
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не - **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
@@ -206,9 +223,9 @@ task gate # весь набор проверок разом
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
секрета. секрета.
- **Что считается сломанным** — новый красный шаг гейта, которого не было до - **Что считается сломанным** — новый красный шаг гейта, которого не было до
твоей правки. Такое чинится прежде любой другой работы. Два объявленных долга твоей правки. Такое чинится прежде любой другой работы. Исключений из этого
из раздела «Гейт» сломанным состоянием **не** считаются, пока их не закрыли правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать
задачами. красный шаг больше не на что.
- **Ориентир по размеру порции:** не замерялся. - **Ориентир по размеру порции:** не замерялся.
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены - **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
поимённо. поимённо.
@@ -218,3 +235,18 @@ task gate # весь набор проверок разом
- Документация, комментарии, сообщения коммитов — русский. - Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский. - Код и идентификаторы — английский.
- Текст, который видит пользователь Telegram, — русский. - Текст, который видит пользователь Telegram, — русский.
- **Точного числа накопленного в документах нет.** «Три capability», «пять
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
такое не считает, а читатель верит написанному. Ссылаться можно только на
**конкретную запись** (по имени, со ссылкой) либо на **весь корпус разом**
(«заведённые capability», «записи журнала ниже»). Само перечисление при этом
законно: перечень обновляют вместе с предметом, а число живёт отдельно от него
и потому протухает в одиночку.
*Изъятие:* число, которое не растёт с работой, остаётся числом — количество
уровней журнала в библиотеке, ступеней сборки образа, состояний списка на
экране. Так же законно **историческое** число в записи о прошлом: «решением от
2026-08-13 закрыты четыре задачи» описывает событие, а не сегодняшний счёт.
Настройки с числовым значением — свой случай, их дом
[docs/database.md](docs/database.md).
+9 -5
View File
@@ -31,7 +31,7 @@
``` ```
3. Скопируйте образец конфига и заполните его: 3. Скопируйте образец конфига и заполните его:
```bash ```bash
cp config.dist.toml config.toml cp config.example.toml config.toml
``` ```
4. Запустите приложение: 4. Запустите приложение:
```bash ```bash
@@ -62,9 +62,13 @@ inv pl -- transcriber
## HTTP API ## HTTP API
Четыре маршрута: `POST /api/audio` — приём записи, `GET /api/status/:id` Семь адресов приложения: `POST /api/audio` — приём записи, `GET /api/status/:id`
готовность задачи, `GET /metrics` — метрики Prometheus с префиксом готовность задачи, `GET /auth/login`, `GET /auth/callback` и
`transcriber_`, `GET /health` — проверка живости. `POST /auth/logout` — вход через провайдера
([access](openspec/specs/access/spec.md)), `GET /metrics` — метрики Prometheus с
префиксом `transcriber_`, `GET /health` — проверка живости. Сверх них тем же
портом отдаётся собственная поверхность встроенного хранилища и панель `/_/` —
[docs/security.md](docs/security.md), «Из чего строятся пути и ключи».
Контракт приёма и опроса нормативен и живёт в Контракт приёма и опроса нормативен и живёт в
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и [openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
@@ -74,7 +78,7 @@ inv pl -- transcriber
## Состояния задач ## Состояния задач
Перечень состояний, переходы между ними и число воркеров — Перечень состояний, переходы между ними и число воркеров —
[docs/database.md](docs/database.md), разделы «Таблицы» и «Представление [docs/database.md](docs/database.md), разделы «Коллекции» и «Представление
данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md). данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md).
## Структура проекта ## Структура проекта
+13 -12
View File
@@ -15,9 +15,9 @@ vars:
# отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам # отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам
# `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается # `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается
# по коду скрипта, а не по коду `task`. # по коду скрипта, а не по коду `task`.
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.py"}}' 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-tasks/skills/tasks/scripts/tasks.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-code/skills/openspec/scripts/openspec.py"}}' OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/code-openspec/scripts/openspec.py"}}'
tasks: tasks:
@@ -89,19 +89,20 @@ tasks:
echo "задай свою: task migrations BASE=<rev>" echo "задай свою: task migrations BASE=<rev>"
exit 3 exit 3
fi fi
# Каталог шагов берётся из docs/.docs.json — там он уже записан ключом # Каталог шагов берётся из .av-dev.toml — там он уже записан ключом
# `migrations` для сверки документов. Свой литерал завёл бы факту второй # `migrations` секции `[docs]` для сверки документов. Свой литерал завёл
# дом: каталог переехал бы, а один из двух стражей молча позеленел. # бы факту второй дом: каталог переехал бы, а один из двух стражей молча
dir=$(python3 -c 'import json,sys; print(json.load(open("docs/.docs.json"))["migrations"])' 2>/dev/null) || dir="" # позеленел. До слияния плагинов файл звался 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 if [ -z "$dir" ] || [ ! -d "$dir" ]; then
echo "каталог шагов схемы не найден: ключ migrations в docs/.docs.json → '$dir'" echo "каталог шагов схемы не найден: ключ [docs] migrations в .av-dev.toml → '$dir'"
exit 3 exit 3
fi fi
# Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от # Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от
# одного переименования — тот же приём, что у правил `internal/archrules`. # одного переименования — тот же приём, что у правил `internal/archrules`.
if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then
echo "в $dir нет ни одного файла шага: правило потеряло предмет" echo "в $dir нет ни одного файла шага: правило потеряло предмет"
echo "поправь шаблон имени в этом шаге либо ключ migrations в docs/.docs.json" echo "поправь шаблон имени в этом шаге либо ключ [docs] migrations в .av-dev.toml"
exit 3 exit 3
fi fi
# Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал» # Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал»
@@ -175,7 +176,7 @@ tasks:
py=$(eval echo {{.DOCS_PY}}) py=$(eval echo {{.DOCS_PY}})
if [ ! -f "$py" ]; then if [ ! -f "$py" ]; then
echo "docs.py не найден: $py" echo "docs.py не найден: $py"
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>" echo "поставь плагин av-dev либо задай путь: task docs DOCS_PY=<путь>"
exit 3 exit 3
fi fi
python3 "$py" check --base {{.BASE}} python3 "$py" check --base {{.BASE}}
@@ -187,7 +188,7 @@ tasks:
py=$(eval echo {{.TASKS_PY}}) py=$(eval echo {{.TASKS_PY}})
if [ ! -f "$py" ]; then if [ ! -f "$py" ]; then
echo "tasks.py не найден: $py" echo "tasks.py не найден: $py"
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>" echo "поставь плагин av-dev либо задай путь: task tasks TASKS_PY=<путь>"
exit 3 exit 3
fi fi
python3 "$py" check --dir tasks python3 "$py" check --dir tasks
@@ -199,7 +200,7 @@ tasks:
py=$(eval echo {{.OPENSPEC_PY}}) py=$(eval echo {{.OPENSPEC_PY}})
if [ ! -f "$py" ]; then if [ ! -f "$py" ]; then
echo "openspec.py не найден: $py" echo "openspec.py не найден: $py"
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>" echo "поставь плагин av-dev либо задай путь: task openspec OPENSPEC_PY=<путь>"
exit 3 exit 3
fi fi
python3 "$py" check --dir . python3 "$py" check --dir .
-68
View File
@@ -1,68 +0,0 @@
# Server configuration
[server]
port = 8080
shutdown_timeout = 5
force_shutdown_timeout = 20
# Storage configuration
# Единственный каталог данных: под ним лежат и база, и файлы записей.
[storage]
data_dir = "data"
# 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/"
# Вход через внешнего провайдера OIDC (Authelia).
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
# API открытым наружу.
[auth]
# Адрес, куда сервис уводит человека на вход
auth_url = "https://auth.example.com/api/oidc/authorization"
# Адрес, где код обменивается на токен
token_url = "https://auth.example.com/api/oidc/token"
# Адрес, откуда берутся сведения о вошедшем
user_info_url = "https://auth.example.com/api/oidc/userinfo"
# Идентификатор клиента, заведённого у провайдера
client_id = "transcriber"
# Секрет клиента; приходит из выкладки, в git не коммитится
client_secret = ""
# Адрес возврата; тот же, что записан клиенту у провайдера
redirect_url = "https://transcriber.example.com/auth/callback"
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
# запуска по http://localhost, где браузер такую куку не сохранит
secure_cookie = true
# Telegram Bot Configuration
[telegram]
# Токен Telegram бота (получить у @BotFather в Telegram)
bot_token = "your_telegram_bot_token_here"
# Таймаут обновлений Telegram бота (в секундах)
update_timeout = 10
+98
View File
@@ -0,0 +1,98 @@
# Server configuration
[server]
port = 8080
shutdown_timeout = 5
force_shutdown_timeout = 20
# Storage configuration
# Единственный каталог данных: под ним лежат и база, и файлы записей.
[storage]
data_dir = "data"
# 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/"
# Вход через внешнего провайдера OIDC (Authelia).
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
# API открытым наружу.
[auth]
# Адрес, куда сервис уводит человека на вход
auth_url = "https://auth.example.com/api/oidc/authorization"
# Адрес, где код обменивается на токен
token_url = "https://auth.example.com/api/oidc/token"
# Адрес, откуда берутся сведения о вошедшем
user_info_url = "https://auth.example.com/api/oidc/userinfo"
# Идентификатор клиента, заведённого у провайдера
client_id = "transcriber"
# Секрет клиента; приходит из выкладки, в git не коммитится
client_secret = ""
# Адрес возврата; тот же, что записан клиенту у провайдера
redirect_url = "https://transcriber.example.com/auth/callback"
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
# запуска по http://localhost, где браузер такую куку не сохранит
secure_cookie = true
# Telegram Bot Configuration
[telegram]
# Нужен ли сервису вход Telegram. Ключ **обязателен**: умолчания у него нет, и
# файл без него негоден — сервис выходит с ошибкой настройки, назвав недостающий
# ключ. Умолчание было бы угаданным намерением, а признак заведён затем, чтобы
# намерение объявляли: любое умолчание делает одну из двух ошибок тихой — либо
# бот молча пропадает, либо файл без признака молча работает.
#
# false — сервис поднимается без Telegram и работает одним входом, по HTTP. Бот
# не заводится, к Telegram не уходит ни одного обращения, записи из Telegram не
# принимаются, а ответы на задачи, принятые оттуда прежде, не уходят —
# недоставка видна записью журнала, расшифровка достаётся из панели и по HTTP.
# О выключенном входе сервис говорит одной записью журнала «к сведению»: это
# выбор владельца, а не отклонение.
#
# true — сервис поднимает бота. Пустой bot_token при этом роняет старт: бота по
# пустому ключу не существует. Старт роняет и ответ Telegram «такого бота нет» —
# это опечатка в ключе, ждать тут нечего. А вот недоступность Telegram (сеть,
# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и предупреждает
# записью журнала, потому что основной вход у него другой.
#
# Локальный прогон идёт с false — боевым токеном запускаться запрещено: второй
# процесс с тем же токеном перехватывает обновления у работающего. Выключенного
# входа для подъёма мало: секции [auth] и [yandex] проверяются на старте и
# роняют процесс на пустых ключах. Наружу при старте не ходит ни одна из них,
# поэтому для локального прогона годятся выдуманные непустые значения — адреса
# [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных ключах
# не работает: её подменяют в коде.
enabled = false
# Токен Telegram бота (получить у @BotFather в Telegram). Только ключ доступа:
# включением входа он больше не заведует, этим занят enabled выше. При
# enabled = false не читается вовсе.
bot_token = ""
# Таймаут обновлений Telegram бота (в секундах)
update_timeout = 10
-4
View File
@@ -1,4 +0,0 @@
{
"canon": 14,
"migrations": "internal/adapter/repo/pocketbase/migrations"
}
@@ -2,6 +2,9 @@
- **Дата:** 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 - **Источник:** [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`
## Решение ## Решение
@@ -10,8 +13,9 @@
собирают. Потребитель у неё другой: тот, кто собирает. собирают. Потребитель у неё другой: тот, кто собирает.
Требование о согласованности объявленной версии Go живёт нормой в Требование о согласованности объявленной версии Go живёт нормой в
[openspec/specs/toolchain/spec.md](../../openspec/specs/toolchain/spec.md), а не `openspec/specs/toolchain/spec.md`, а не прозой в памятке. *Уточнено 2026-08-13:
прозой в памятке. файла по этому адресу больше нет, ссылка снята — capability упразднена, см.
статус записи.*
## Почему ## Почему
@@ -0,0 +1,66 @@
# Намерение объявляется признаком, а не выводится из ключа доступа
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
## Решение
Вход Telegram включается отдельным признаком `telegram.enabled`, а `bot_token`
означает только доступ. Признак **обязателен**: умолчания у него нет, и файл
настроек без него негоден — сервис выходит с ошибкой настройки, назвав
недостающий ключ.
## Почему
Прежде пустой ключ доступа значил разом две вещи — «вход выключен намеренно» и
«ключа нет», — и сервис поднимался без бота в обоих случаях. Цена расхождения
падала на выкладку: файл настроек собирает Ansible, и потерянный при сборке ключ
выглядел для сервиса как решение владельца.
Умолчания у признака нет, и это **намеренный отказ от очевидного подхода**
булев ключ обычно заводят с умолчанием. Цитата из источника:
> умолчание — это угаданное намерение, а признак заводится ровно затем, чтобы
> намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе
> стороны, и любое умолчание делает одну из двух ошибок тихой.
Отвергнуты оба умолчания. «Включён» — файл без признака работал бы «как-нибудь»,
и разница между объявленным и угаданным намерением исчезала бы ровно там, где её
завели. «Выключен» — первый же подъём после выкладки выключил бы бота молча, то
есть дал бы исход, против которого написано само требование.
Отсутствие ключа судит **разбор**, а не значение: `toml.MetaData.IsDefined`
отличает «не задан» от «задан ложным», тогда как нулевое значение `bool` у обоих
одинаковое. Форма поля с указателем отвергнута: указатель пережил бы проверку и
уехал к потребителям, где `nil` уже невозможен, но выглядит возможным.
Тем же решением закрыт разрез текста отказа при разборе файла настроек. Цитата
из источника:
> Пересказывать библиотеку нельзя: она собирает текст отказа из разбираемого
> куска файла, и оборванная строка секретного ключа уехала бы в журнал вместе со
> значением.
Норму держит инвариант «Секрет не покидает конфиг», а форму записи — конвенция
настроек. Спеки загрузку настроек не нормируют, и это назначено явно: загрузка
не принадлежит ни одной заведённой capability.
## Последствия
- `+` потерянный при сборке файла ключ доступа роняет старт вслух, а не оставляет
сервис работать в половину силы;
- `+` выключенный вход перестал быть поводом для предупреждения: решение
владельца сообщается записью «к сведению», а предупреждение осталось за тем,
чего владелец не выбирал, — недоступностью Telegram;
- `+` оборванная строка секретного ключа больше не уносит значение в журнал
контейнера;
- `` **порядок выкладки стал обязательным**: шаблон настроек обязан получить
признак раньше накатки образа, иначе сервис не поднимется вовсе. Правило живёт
в [architecture.md](../architecture.md), раздел «Эксплуатация», и задаётся там
по ключу, а не по файлу целиком;
- `` один путь молчаливой потери бота остался: признак, ошибочно собранный
как «выключен», отличим от решения владельца только записью журнала. Признак
поднятости входа тут не помощник — он равен нулю и при недоступности Telegram;
- `` отказ разбора файла настроек стал беднее на текст библиотеки: место и ключ
названы, а что именно в строке не так — нет. Плата принята ради инварианта,
помеченного необратимым.
@@ -0,0 +1,66 @@
# Недоступность Telegram подъёму сервиса не мешает
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.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).
+7 -2
View File
@@ -21,7 +21,10 @@
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято. - Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`. `ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
- Записи неизменяемы: передумали — новая запись, старой ставится статус. - Записи неизменяемы **в решении**: передумали — новая запись, старой ставится
статус. Уточнить прежнюю запись можно только строкой «*Уточнено ГГГГ-ММ-ДД:*» в
разделе «Последствия» и только фактом, который решения не меняет, — например
действующим адресом того, что решение завело.
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и - Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и `устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
источником, а не абзацем в теле. источником, а не абзацем в теле.
@@ -32,11 +35,13 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | | | 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | | | 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.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) | | | 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.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 | [Объявленную версию 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 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](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-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
+60 -32
View File
@@ -5,23 +5,29 @@
помечены маркером долга и переезжают туда первой же задачей, которая их трогает. помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из [passport.md](passport.md) и в [tasks/BACKLOG.md](../tasks/BACKLOG.md); что из
этого ещё не решено — в разделе «Открытые вопросы». этого ещё не решено — в разделе «Открытые вопросы».
Заведены пять capability. Четыре первые нормируют **поведение сервиса** для его Заведённые capability нормируют **поведение сервиса** для его потребителей —
потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а все до одной. Инструмент, которым сервис собирают, спеками не нормируется вовсе:
инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает. у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением
от 2026-08-13 его нормы живут в самих шагах, их проверках и
[conventions/go-linters.md](conventions/go-linters.md).
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: приём и - [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала, входов**: приём и опрос за сессией, имя отправителя не доходит ни до
метка метрики несёт только известное расширение. Задачи хранилища, ни до журнала, метка метрики несёт только известное расширение, а
выключенный вход Telegram не мешает подъёму. Задачи
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
`pocketbase-storage` и `oidc-login` 2026-08-12. Приём из Telegram здесь не `pocketbase-storage` и `oidc-login` 2026-08-12,
описан; `local-run-without-telegram-token` 2026-08-13. Приём из Telegram по существу —
кто допущен и как забирается запись — здесь по-прежнему не описан;
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед задачи и срок его протухания, число попыток, состояние «мертва», пауза перед
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и повтором и недоставленный ответ отправителю: задачи
`pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются `errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12 и
`local-run-without-telegram-token` 2026-08-13. Переходы состояний и отмена
контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
@@ -30,11 +36,7 @@
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login` её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший 2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
видит всё, что видел прежде аноним; видит всё, что видел прежде аноним.
- [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой
его версии собирается сервис: одно число версии Go во всех местах, где она
названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade`
2026-08-12.
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
коде. Задача, которая его трогает, дописывает спеку своей capability. коде. Задача, которая его трогает, дописывает спеку своей capability.
@@ -68,7 +70,7 @@
Каждый — строкой со ссылкой на capability, а не пересказом её требований. Каждый — строкой со ссылкой на capability, а не пересказом её требований.
<!-- канон: поведение → openspec/specs/intake, delivery --> <!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приём из Telegram, деление длинного текста по словам -->
| Компонент | Где | Что делает | | Компонент | Где | Что делает |
| --- | --- | --- | | --- | --- | --- |
@@ -81,14 +83,15 @@
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам | | Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом | | Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | | Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода | | Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки задачи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано --> <!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: цепочка переходов состояний -->
Конвейер: `created``converted``transcribe``done` либо `failed`. Три Конвейер: `created``converted``transcribe``done` либо `failed`. Каждый
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Задача, переход двигает свой воркер, и каждый опрашивает базу раз в секунду. Что
исчерпавшая попытки, уходит в `dead` мимо этой цепочки: её переводит туда не шаг, делает задача, исчерпавшая попытки, нормирует
а тот, кто её захватил. [pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
«мертва»».
## Внешние границы и форматы ## Внешние границы и форматы
@@ -111,15 +114,35 @@
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном - **Где работает, что рядом, кто перезапускает:** один контейнер на личном
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом — сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
обратный прокси, который публикует HTTP-порт наружу. обратный прокси, который публикует HTTP-порт наружу.
- **Порядок выкладки задаётся по ключу, а не по файлу целиком.** Общего правила
«сперва образ» или «сперва конфиг» нет: два ключа секции Telegram требуют
противоположного, и оба правила действуют одновременно.
- **Признак включения `telegram.enabled` едет в конфиг раньше образа.** Он
обязателен с 2026-08-13, умолчания у него нет, и образ, который его ждёт,
без него выходит с кодом 1 **до** открытия порта — вместе с HTTP, панелью и
конвейером. Прежний образ лишний ключ TOML просто не читает, поэтому ранняя
правка конфига безопасна, а поздняя роняет сервис.
- **Пустой ключ доступа `telegram.bot_token` едет позже образа.** Образы
старше 2026-08-13 роняли старт на пустом ключе, тоже до открытия порта.
- **Откат при выключенном входе** допустим только на образ от 2026-08-13 и
новее. На более старом состояния «сервис поднят, бот опущен» не существует
вовсе: пустой ключ роняет старт, негодный роняет старт, годный поднимает
бота. Откат туда делают с непустым годным ключом, приняв, что бот поднимется.
- **Откат образа при `enabled = false` и заполненном ключе** отменяет решение
владельца молча: прежний образ признака не видит и поднимает бота. Если вход
был выключен потому, что бот с этим токеном поднят где-то ещё, два процесса
поделят один длинный опрос и часть ответов до людей не дойдёт.
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»: наружу — [database.md](database.md), «Настройки с числовым значением»:
<!-- канон: поведение → openspec/specs/conversion, recognition --> <!-- канон: поведение → openspec/specs/intake, pipeline -->
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | | Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
@@ -132,7 +155,7 @@
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера. `transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
Отдельного оповещения нет. Отдельного оповещения нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос, - **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
три воркера опрашивают базу вхолостую с паузой из воркеры опрашивают базу вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением». [database.md](database.md), «Настройки с числовым значением».
## Единые точки проекта ## Единые точки проекта
@@ -191,8 +214,11 @@
текст расшифровки начинает уходить на сторону — сдвиг периметра текст расшифровки начинает уходить на сторону — сдвиг периметра
[security.md](security.md). [security.md](security.md).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём - **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётный из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные
потолок проекта — шесть часов, и он взят с запасом, а не замером. шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
записи живёт в хранилище»; откуда взято число —
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
записка не узнала».
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart - **Приём большого файла.** Форма читается целиком, предел памяти под multipart
задан числом в [database.md](database.md), «Настройки с числовым значением»; задан числом в [database.md](database.md), «Настройки с числовым значением»;
обрыв начинает загрузку заново. обрыв начинает загрузку заново.
@@ -216,9 +242,11 @@
конвертер этот случай не проверялся. конвертер этот случай не проверялся.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12 - **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
дают 259 200 запросов в сутки при нагрузке в единицы записей в день, и во что даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
это обходится, никто не мерил. паузы, а не замер
([research/job-queue.md](research/job-queue.md), «Как снималось»), — при
нагрузке в единицы записей в день, и во что это обходится, никто не мерил.
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать - **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
+7 -7
View File
@@ -21,10 +21,11 @@ severity — в [CLAUDE.md](../../CLAUDE.md).
UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg` UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg`
предложение с заглавной буквы вместо константной категории. предложение с заглавной буквы вместо константной категории.
Из этого перечня закрыты два. Доменные ошибки проверялись приведением типа до Часть перечня закрыта. Доменные ошибки проверялись приведением типа до
2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по 2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по
месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило
держит линтер. Оба места больше не долг, а регрессия. держит линтер. Образец конфига звался `config.dist.toml` до 2026-08-14, задача
`config-example-toml`. Эти места больше не долг, а регрессия.
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком. htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
@@ -42,16 +43,15 @@ htmx, а здесь решено делать SPA — и перенесённы
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против `errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
типизированной. типизированной.
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл - [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
`0600`, самодокументируемый `config.dist.toml`, проверка на старте. `0600`, самодокументируемый `config.example.toml`, проверка на старте.
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT - [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
ULID, разбор на входной границе, естественные ключи у деталей. ULID, разбор на входной границе, естественные ключи у деталей.
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике, - [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
над `fetch`, показ ошибок и состояний списка. над `fetch`, показ ошибок и состояний списка.
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: лестница - [go-linters.md](go-linters.md) — линтеры и механизированные проверки: два круга
механизации, два круга (pre-commit и гейт), перечень правил и подавлений, (pre-commit и гейт), перечень правил и подавлений, порядок заведения нового
порядок заведения нового правила. Про инструменты, а не про то, как писать правила. Про инструменты, а не про то, как писать тесты.
тесты.
## Что из этого проверяет машина ## Что из этого проверяет машина
+49 -20
View File
@@ -4,9 +4,9 @@
Правила оформления кода (How), не спецификация поведения. Правила оформления кода (How), не спецификация поведения.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту. **Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: образец называется `config.dist.toml`, а не `config.example.toml`; Главные: комментариями снабжена половина полей; единого места проверки на старте
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые
проверки пустых ключей внутри адаптеров. ключи `[yandex]` ловит конструктор распознавателя.
**Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml` **Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml`
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки ([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
@@ -29,13 +29,13 @@
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге** - Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
процесса. процесса.
- Путь переопределяется опцией **`-c path`** или **`--config=path`**. - Путь переопределяется опцией **`-c path`** или **`--config=path`**.
- Образец в репозитории — **`config.dist.toml`** (см. ниже); реальный - Образец в репозитории — **`config.example.toml`** (см. ниже); реальный
`config.toml` не коммитится. `config.toml` не коммитится.
## config.dist.toml — самодокументируемый образец ## config.example.toml — самодокументируемый образец
`config.dist.toml` коммитим как единый справочник по конфигу: все секции и все `config.example.toml` коммитим как единый справочник по конфигу: все секции и
поля. **Каждое поле снабжаем комментарием**, из которого ясно: все поля. **Каждое поле снабжаем комментарием**, из которого ясно:
- **зачем** поле — что оно меняет в поведении; - **зачем** поле — что оно меняет в поведении;
- **допустимые значения** — перечисление или границы; - **допустимые значения** — перечисление или границы;
@@ -53,12 +53,12 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
одним домом — таблица «Настройки с числовым значением» в одним домом — таблица «Настройки с числовым значением» в
[../database.md](../database.md); `config.dist.toml` — источник истины по составу [../database.md](../database.md); `config.example.toml` — источник истины по
полей. составу полей.
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»). Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля *Расхождение:* секции `[server]` в `config.example.toml` не хватает поля
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем. `users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами *Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
@@ -75,7 +75,7 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор - **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
обязательных полей; поля других значений не требуются. Неизвестное значение — обязательных полей; поля других значений не требуются. Неизвестное значение —
ошибка на старте с перечислением поддерживаемых. ошибка на старте с перечислением поддерживаемых.
- **Образец — по `type`.** В `config.dist.toml`: - **Образец — по `type`.** В `config.example.toml`:
- основное (умолчательное) значение **предзаполнено** рабочими значениями; - основное (умолчательное) значение **предзаполнено** рабочими значениями;
- альтернативные — **блоками-комментариями ниже**, каждый со своим описанием - альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
полей (зачем, границы, единицы — как у обычных полей); полей (зачем, границы, единицы — как у обычных полей);
@@ -96,12 +96,23 @@ Ansible из `pet-project-server`). Приложение просто читае
`yandex.object_storage_secret_access_key`, `auth.client_secret`. `yandex.object_storage_secret_access_key`, `auth.client_secret`.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`, - Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`). владелец — пользователь процесса (`1000:1000`).
- В `config.dist.toml` секретные поля — пустые строки. - В `config.example.toml` секретные поля — пустые строки.
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые *Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
строки, и загрузчик их не отличает от настоящего значения. строки, и загрузчик их не отличает от настоящего значения.
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво - Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
отрендеренный файл) — см. «Проверка и остановка на старте». отрендеренный файл) — см. «Проверка и остановка на старте».
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность». - В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
- **Отказ загрузки настроек не несёт содержимого файла.** Текст такого отказа
собирает библиотека разбора, и собирает она его из разбираемого куска:
`toml.ParseError` кладёт в сообщение само значение («Invalid float value: %q»).
Оборванная кавычка в строке секретного ключа — типовая поломка криво
отрендеренного шаблона выкладки — уносит ключ в журнал контейнера целиком, а
инвариант «секрет не покидает конфиг» помечен необратимым. Поэтому отказ
разбора пересобирается своими словами: путь, строка, столбец и последний ключ,
без сообщения библиотеки. Прочие отказы декодера (несовпадение типов,
неподдерживаемый тип) собраны из имён ключей и типов, значений в них нет, и их
текст остаётся как есть — иначе за разборчивость отказа платили бы там, где
платить не за что.
## Проверка и остановка на старте ## Проверка и остановка на старте
@@ -116,10 +127,19 @@ Ansible из `pet-project-server`). Приложение просто читае
- ключи внешних сервисов не пусты. - ключи внешних сервисов не пусты.
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает *Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
приложение продолжает работу без бота; пустые ключи Yandex ловятся в выходит с кодом 1. Единого места проверки нет.
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
места проверки нет. Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак
включения и ключ доступа, — и проверок у них две. Третий ключ секции,
`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает
длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает
«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у
обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из
`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true`
ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке
клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак
включения решает, поднимается ли вход Telegram».
Секция `[auth]` — первая, у которой проверка своя и стоит на старте: Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет `AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
@@ -131,10 +151,19 @@ TOML. Пустой токен бота ловится в `NewTelegramController`
## Структура в коде ## Структура в коде
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`. - Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
- Одна корневая структура `Config` с под-структурами по секциям. Перечень секций - Одна корневая структура `Config` с под-структурами по секциям. Перечень
и полей здесь не повторяем: источник истины по составу — `config.dist.toml`, секций и полей здесь не повторяем: источник истины по составу —
действующие числа — [../database.md](../database.md), «Настройки с числовым `config.example.toml`, действующие числа — [../database.md](../database.md),
значением». Каталог данных задаётся одним ключом `[storage] data_dir` «Настройки с числовым значением». Каталог данных задаётся одним ключом
`[storage] data_dir`
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)). ([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле - Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
требует правки обоих мест. требует правки обоих мест.
- **Обязательное поле — поле, у которого умолчания нет намеренно.** Умолчание у
такого поля было бы угаданным намерением, и одна из двух ошибок стала бы
тихой. Форма записи: умолчания нет ни в `defaultConfig()` (причина — строкой
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
судит **разбор**`MetaData.IsDefined` из `toml.DecodeFile`, — потому что
значение отличить «не задано» от «задано нулём» не позволяет. В
`config.example.toml` у поля стоит значение свежей установки. Первое такое
поле — `telegram.enabled`.
+5
View File
@@ -58,6 +58,11 @@
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`). лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени. вставка падает громко. Измерение длительности — не метка времени.
*Расхождение:* вид времени задаёт хранилище — `2006-01-02 15:04:05.000Z`,
пробел вместо `T` и доли секунды ([../database.md](../database.md), «Время»).
Правило RFC 3339 действует на то, что пишем мы сами мимо хранилища; вид
хранилища не меняем — сравнение строк в сыром запросе побайтово, и
разошедшийся вид молча обращает условие срока захвата в константу.
- Миграции — шаги PocketBase на Go - Миграции — шаги PocketBase на Go
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их (`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
поля заводятся кодом. При изменении структуры обновляем схему поля заводятся кодом. При изменении структуры обновляем схему
+8 -2
View File
@@ -65,9 +65,15 @@ transcriber — **приложение, а не библиотека**: внеш
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там, вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
где хватает sentinel. где хватает sentinel.
Сегодня в проекте три типизированные ошибки, и данные несёт только одна: Типизированные ошибки проекта несут данные все до одной:
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError` `contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel). (состояние), `contract.LostAcquisitionError` (идентификатор задачи).
`tg.EmptyBotTokenError` был ровно тем случаем, против которого написано правило —
тип без полей, — и снят задачей `local-run-without-telegram-token` 2026-08-13;
его место занял sentinel `telegram.ErrEmptyToken`. Рядом живёт
`contract.ErrDeliveryChannelDown` — тоже sentinel и по той же причине: заглушка
отправителя не знает ни задачи, ни чата, и нести ей нечего.
## Граница и трансляция: приватный и публичный канал ## Граница и трансляция: приватный и публичный канал
+25 -54
View File
@@ -9,17 +9,16 @@
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест [../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
обязан проверять, и там же записано требование, чтобы проверка была **способна обязан проверять. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
упасть**. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
потому, что они правила: у них нет ни фикстур, ни поведения — они читают потому, что они правила: у них нет ни фикстур, ни поведения — они читают
исходники. исходники.
Пока язык у проекта один, и запись названа по нему. Появится второй — у него Пока язык у проекта один, и запись названа по нему. Появится второй — у него
будет своя запись, а лестница и два круга останутся общими. будет своя запись, а два круга останутся общими.
Устройство ниже **переносимо**: разделы «Лестница механизации», «Два круга» и Устройство ниже **переносимо**: разделы «Два круга» и «Как заводят новое
«Как заводят новое правило» — не особенность transcriber и переносятся в другой правило» — не особенность transcriber и переносятся в другой Go-проект как есть.
Go-проект как есть. Своё здесь — перечень правил и подавлений. Своё здесь — перечень правил и подавлений.
## Границы: где что живёт ## Границы: где что живёт
@@ -36,45 +35,17 @@ Go-проект как есть. Своё здесь — перечень пра
- **настройка конвейера ревью, вопросы по темам и журнал дефектов** — - **настройка конвейера ревью, вопросы по темам и журнал дефектов** —
[../review.md](../review.md). Перечень ниже говорит этим вопросам, чего [../review.md](../review.md). Перечень ниже говорит этим вопросам, чего
спрашивать уже не нужно; спрашивать уже не нужно;
- **поведение сервиса** — нормативные спеки `openspec/specs/`. У шага сверки - **поведение сервиса** — нормативные спеки `openspec/specs/`. Шаги набора
версий Go поведение нормировано отдельно, спекой проверок туда не входят: инструментарий спеками не нормируется, и спека
[toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка `toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Своего
проекта, у которой есть своя capability, и потому единственная, чьи сценарии дома у нормы этого шага теперь нет вовсе — она живёт комментариями в
проверяются построчно (`scripts/check_go_version_test.go`). Второй самодельный `scripts/check-go-version.sh`, и проверок у шага нет: двадцать сценариев снесены
шаг — `migrations` — нормы не имеет: он проверен мутацией на трёх исходах тем же решением. Второй самодельный
шаг — `migrations` — не проверен и не был: он прогнан мутацией на трёх исходах
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у (переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
него нет, и дрейф его собственного шаблона имени никто не поймает. Это него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это
объявленный долг, а не умолчание. не числится: проверок над проверками проект не заводит —
[CLAUDE.md](../../CLAUDE.md), «Запреты».
## Лестница механизации
Свойство поднимается по ступеням, и ступень выбирают не по вкусу, а по тому,
чем свойство выражается. Верхняя ступень дешевле нижней в эксплуатации и дороже
в заведении, поэтому прыгать через ступень без нужды не надо.
1. **Проза конвенции.** Свойство названо словами, проверяет человек на каждом
ревью заново. Это ступень по умолчанию и худшая из всех: она стоит внимания
каждого прогона и молча перестаёт работать, когда внимание кончилось.
2. **Настройка готового линтера.** Свойство совпало с чужим правилом —
включается строкой в `.golangci.yml`. Дешевле всего; ограничение в том, что
правило чужое и говорит о том, о чём его написали.
3. **Запрет по имени** (`forbidigo`, `depguard`). Свойство выражается через «эту
функцию/пакет тут звать нельзя». Дешёво и точно, но требует **единой точки**,
куда запрещённое переносят: запрет без дома оставляет код без способа сделать
нужное.
4. **Тест-сканер исходников** (`internal/archrules`). Свойство — о структуре, а
не о вызове: направление зависимостей, согласованность двух перечней,
отсутствие идиомы. Пишется руками на `go/parser` или регулярном выражении,
зато читается как тест и ломается заметно.
5. **Свой шаг проверки** (`scripts/`, шаги `Taskfile.yml`). Свойство выходит за
пределы кода на Go: версия инструмента, форма `Dockerfile`, раскладка
документов. Дороже всех — у шага появляется своя норма и свои тесты.
Ступень, выбранная неверно, видна сразу. Запрет по имени, обходимый одной
лишней строкой, — это ступень 4, наряженная третьей: так было с правилом о
заголовках ответа, которое сначала запретило текст `\.Header\(\)\.Get`, а
обходилось присваиванием в переменную. Правило переписано на суждение **по типу
приёмника** (`analyze-types`), и это уже настоящая третья ступень.
## Два круга: pre-commit и гейт ## Два круга: pre-commit и гейт
@@ -120,7 +91,7 @@ Go-проект как есть. Своё здесь — перечень пра
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` | | Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` | | Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` | | Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда | | Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
### Отмена и внешний собеседник ### Отмена и внешний собеседник
@@ -139,14 +110,13 @@ Go-проект как есть. Своё здесь — перечень пра
| Конфигурация приезжает из TOML, а не из окружения | `.golangci.yml``forbidigo`: `os.Getenv`, `os.LookupEnv`, `os.Environ`, `os.ExpandEnv` — все четыре, иначе запрет обходится соседним именем | | Конфигурация приезжает из TOML, а не из окружения | `.golangci.yml``forbidigo`: `os.Getenv`, `os.LookupEnv`, `os.Environ`, `os.ExpandEnv` — все четыре, иначе запрет обходится соседним именем |
| Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml``sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) | | Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml``sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) |
### Проверки о самих проверках ### Код проверок и подавления
| Правило | Где механизировано | | Правило | Где механизировано |
| --- | --- | | --- | --- |
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` | | Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
| Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | `scripts/check_go_version_test.go` — 20 сценариев спеки `toolchain` плюс два свойства самого шага: исход не зависит от установленного `go`, и шаг не зовёт ни `go`, ни `docker`, ни сеть |
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` | | Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Без компилятора C шаг гоняет тесты без детектора и краснеет кодом 3: гонки — не повод отнимать у гейта сами тесты | | Одновременный доступ проверен детектором, а не чтением кода | `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`) | | Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml``nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
### Форма кода и файлов вне Go ### Форма кода и файлов вне Go
@@ -164,8 +134,8 @@ Go-проект как есть. Своё здесь — перечень пра
| Правило | Где механизировано | | Правило | Где механизировано |
| --- | --- | | --- | --- |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` в `docs/.docs.json`, чтобы у факта не было второго дома; пустой каталог роняет шаг — правило, потерявшее предмет, молчать не должно. `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там | | Применённый шаг схемы не переписывается: у файла шага допустим один статус — `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/.docs.json` | | Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` | | Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` | | Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
| Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) | | Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) |
@@ -196,8 +166,8 @@ Go-проект как есть. Своё здесь — перечень пра
остаётся то, чему нет ни готового правила, ни детерминированного оракула: остаётся то, чему нет ни готового правила, ни детерминированного оракула:
уровень лога по адресату, единая логирующая точка на доменной границе, словарь уровень лога по адресату, единая логирующая точка на доменной границе, словарь
имён полей, канонический вид идентификатора, естественные ключи у деталей. имён полей, канонический вид идентификатора, естественные ключи у деталей.
Свойство, оставшееся прозой, проверяет человек на каждом ревью заново — это и Свойство, оставшееся прозой, проверяет человек на каждом ревью заново, и правило,
есть первая ступень лестницы, и подъём с неё всегда выигрыш. снявшее с него эту работу, всегда выигрыш.
Названы поимённо и **остатки правил** — то, что правило не ловит и потому Названы поимённо и **остатки правил** — то, что правило не ловит и потому
осталось человеку: осталось человеку:
@@ -239,8 +209,9 @@ Go-проект как есть. Своё здесь — перечень пра
Порядок один и тот же, и последние два шага пропускать нельзя. Порядок один и тот же, и последние два шага пропускать нельзя.
1. **Найти дом.** Ступень лестницы выбирается по тому, чем свойство 1. **Найти дом.** Дом выбирается по тому, чем свойство выражается, а не по тому,
выражается, а не по тому, что проще включить. что проще включить: настройка готового линтера, запрет по имени, тест-сканер
исходников или свой шаг набора проверок.
2. **Написать причину рядом.** Правило без причины снимают при первом же 2. **Написать причину рядом.** Правило без причины снимают при первом же
неудобстве: тот, кто снимает, не знает, что оно ловило. неудобстве: тот, кто снимает, не знает, что оно ловило.
3. **Починить находки, а не подавить.** Подавление годится, когда правило 3. **Починить находки, а не подавить.** Подавление годится, когда правило
+3 -4
View File
@@ -163,8 +163,7 @@ log := log.With("job_id", job.Id, "capability", "conversion")
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и *Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
записи. Плюс `internal/controller/http/transcribe.go` пишет через `log.Printf` записи.
мимо `slog` целиком.
## Внешние сервисы: логируем все вызовы ## Внешние сервисы: логируем все вызовы
@@ -241,9 +240,9 @@ Object Storage, скачивание файла из Telegram и опрос оп
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта. в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
Разговор с Telegram этому правилу следует, и точка чистки одна на все вызовы — Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы —
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к `internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из пяти: Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех:
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а - отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`, библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
+17 -9
View File
@@ -33,11 +33,13 @@
- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени - **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени
выполнения нет. выполнения нет.
- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница - **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница
цели [web-access](../../tasks/items/web-access.md). Без сети приложение из [паспорта](../passport.md). Без сети приложение показывает состояние, а не
показывает состояние, а не пустой экран. пустой экран.
- **Web Push не делаем**: уведомления идут через apprise и ntfy, цель - **Web Push не делаем**: уведомления идут через apprise и ntfy — решение живёт
[ready-notification](../../tasks/items/ready-notification.md). в [architecture.md](../architecture.md), «Уведомления», делает его
- **Записи звука в приложении не делаем** — файл выбирают системным диалогом. [ntfy-delivery](../../tasks/items/ntfy-delivery.md).
- **Записи звука в приложении не делаем** — граница из
[паспорта](../passport.md), «Диктофон»; файл выбирают системным диалогом.
## Фреймворк и сборка ## Фреймворк и сборка
@@ -55,9 +57,12 @@
## Маршруты ## Маршруты
- **Четыре экрана, одна таблица маршрутов** через `createRouter`. Маршруты по - **Одна таблица маршрутов** через `createRouter`. Маршруты по файлам не
файлам не включаем: сборочная надстройка роутера пятой версии стоит 34 пакета включаем: сборочная надстройка роутера пятой версии стоит 34 пакета в
в установке и на четырёх маршрутах не окупается. установке и на нашем числе маршрутов не окупается
([research/spa-framework.md](../research/spa-framework.md), «Vue»). Сколько
экранов и какие — не здесь: состав нормирует спека приложения, а до неё его
держит [spa-skeleton](../../tasks/items/spa-skeleton.md).
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование - **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`; к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`;
пути внутри `/api/` в приложение не проваливаются никогда. пути внутри `/api/` в приложение не проваливаются никогда.
@@ -78,6 +83,9 @@
- **Обёртка — единственное место, где читается код ответа.** Она же превращает - **Обёртка — единственное место, где читается код ответа.** Она же превращает
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
не `Response`. не `Response`.
- **Сессия живёт кукой `transcriber_session`**, и приложение её не читает: кука
`HttpOnly`, браузер шлёт её сам, а вошедшего экран узнаёт по ответу API. Норма
— [access](../../openspec/specs/access/spec.md).
## Показ ошибок и состояний ## Показ ошибок и состояний
@@ -100,5 +108,5 @@
узнала»). узнала»).
- **Устройство service worker и версионирование статики** — задача - **Устройство service worker и версионирование статики** — задача
[installable-pwa](../../tasks/items/installable-pwa.md). [installable-pwa](../../tasks/items/installable-pwa.md).
- **Где живёт сессия и как приложение узнаёт вошедшего** — открытый вопрос - **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос
«Учётные записи» в [../architecture.md](../architecture.md). «Учётные записи» в [../architecture.md](../architecture.md).
+13 -10
View File
@@ -16,8 +16,8 @@ CGO сборке не нужен.
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя: Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
пути** (`docs/.docs.json`, ключ `migrations`), а префикс наводится только на пути**, а префикс наводится только на каталог. Где этот префикс задан —
каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет [conventions/go-linters.md](conventions/go-linters.md), «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
репозитория берёт их оттуда. репозитория берёт их оттуда.
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита. **Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
@@ -39,7 +39,7 @@ CGO сборке не нужен.
### `files` ### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в Один файл на одну физическую копию: исходник, результат конвертации и копия в
Object Storage — три разные записи. Object Storage — каждая своей записью.
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
@@ -78,10 +78,10 @@ capability, и третий смысл развёл бы одно слово п
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
этот один. этот один.
**Состояния `failed` и `dead` — разные приговоры.** В `failed` задачу переводит **Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует
шаг, рассудивший об этой записи окончательно; в `dead` она уходит без такого [pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
суждения — мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит «мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
сам: это делает тот, кто захватил задачу с превышенным счётчиком. потребует нового шага.
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи **Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
может только владелец панели. Проверено прогоном: анонимный запрос к может только владелец панели. Проверено прогоном: анонимный запрос к
@@ -118,8 +118,10 @@ capability, и третий смысл развёл бы одно слово п
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
заведения **и по ключу**: время неуникально, и без ключа порядок обработки заведения **и по ключу**: время неуникально, и без ключа порядок обработки
невоспроизводим. невоспроизводим.
- **Запись результата условна по признаку захвата.** Шаг, чей захват за время - **Запись результата условна по признаку захвата** — инвариант «Результат пишет
работы достался другому, завершается без записи и без ответа отправителю. только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
что условие проверяется тем же запросом, что и сам захват.
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`, - **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы. константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
@@ -145,10 +147,11 @@ capability, и третий смысл развёл бы одно слово п
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — | | Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
| Срок жизни сессии | 7 суток | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки в 5 суток никем не выбрано | | Срок жизни сессии | нормирует [access](../openspec/specs/access/spec.md) | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен | | Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым | | Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
+7 -7
View File
@@ -1,7 +1,7 @@
# Паспорт проекта # Паспорт проекта
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «в каком порядке», паспорт — устроено», [tasks/BACKLOG.md](../tasks/BACKLOG.md) — «в каком порядке», паспорт —
«зачем и для кого». «зачем и для кого».
## Цель ## Цель
@@ -22,7 +22,7 @@
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось | | Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи | | Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня | | Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только кука, снятая из браузера. Токен приносит `api-tokens` | | Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11 **Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
@@ -32,8 +32,8 @@
- запись любого распространённого формата принимается без предварительной - запись любого распространённого формата принимается без предварительной
подготовки, включая дорожку из видео; подготовки, включая дорожку из видео;
- запись длиной до шести часов доходит до текста, а не прерывается ошибкой при - запись расчётного потолка — шести часов доходит до текста, а не прерывается
достижении предела; ошибкой при достижении предела (норма — `openspec/specs/storage`);
- сервисом пользуются несколько человек, и записи одного не видны другому; - сервисом пользуются несколько человек, и записи одного не видны другому;
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек - текст доступен там же, где загружали, — в приложении и в Telegram. Человек
узнаёт о его готовности, не держа приложение открытым; узнаёт о его готовности, не держа приложение открытым;
@@ -54,7 +54,8 @@
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за - **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него». 2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него».
Направление — цель [text-insights](../tasks/items/text-insights.md). Считать уровни текста берётся задача
[llm-insights-adapter](../tasks/items/llm-insights-adapter.md).
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания, - **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
ни языковую модель: и речь, и выводы из текста считает внешний сервис. ни языковую модель: и речь, и выводы из текста считает внешний сервис.
- **Управление учётными записями.** Пользователей заводит и проверяет внешний - **Управление учётными записями.** Пользователей заводит и проверяет внешний
@@ -65,8 +66,7 @@
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не - **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем. обрабатываем.
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый - **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
файл. Своей записи и работы без сети не делаем — граница цели файл. Своей записи и работы без сети не делаем.
[web-access](../tasks/items/web-access.md).
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради - **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
речи в них. Складом произвольных файлов и папками сервис не становится. Общего речи в них. Складом произвольных файлов и папками сервис не становится. Общего
доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у
+1
View File
@@ -22,6 +22,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
| Дата | Запись | О чём | | Дата | Запись | О чём |
| --- | --- | --- | | --- | --- | --- |
| 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-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 | | 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 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
+3 -2
View File
@@ -49,8 +49,9 @@
## Захват чинится одним запросом ## Захват чинится одним запросом
Сегодняшний захват — два запроса подряд без транзакции Захват **на момент замера** — два запроса подряд без транзакции; после перехода
([../database.md](../database.md), «Представление данных»). Замер показал, что на PocketBase он свернулся в один с `RETURNING`
[../database.md](../database.md), «Представление данных». Замер показал, что
после перехода на PocketBase он сворачивается в один: движок за после перехода на PocketBase он сворачивается в один: движок за
`modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх `modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх
горутинах разом запись получила **ровно одна**. горутинах разом запись получила **ровно одна**.
+7 -3
View File
@@ -90,6 +90,10 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
`modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано `modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано
сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает. сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает.
*Уточнено 2026-08-12:* перевод состоялся, и требования CGO в стеке больше нет —
[../../CLAUDE.md](../../CLAUDE.md), «Стек»: компилятор C нужен только детектору
гонок в гейте.
Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения
(`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота, (`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота,
ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода, ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода,
@@ -101,9 +105,9 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
## Вход через OIDC: что выяснилось при реализации ## Вход через OIDC: что выяснилось при реализации
Дописано 2026-08-12 задачей `oidc-login`. Провенанс общий: чтение исходников Дописано 2026-08-12 задачей `oidc-login`. Все находки ниже получены одним
`pocketbase@v0.39.10` из кеша модулей плюс прогоны против настоящего хранилища на способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
временном каталоге, все — в ходе ревью того change. Живой Authelia в прогонах не против настоящего хранилища на временном каталоге — в ходе ревью того change. Живой Authelia в прогонах не
было ни разу: провайдера подменял свой `httptest`-сервер. было ни разу: провайдера подменял свой `httptest`-сервер.
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с **Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
+2 -1
View File
@@ -25,7 +25,8 @@ Nuxt, Next — не рассматривали: конвенция
правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает
одинаковость экрана. одинаковость экрана.
- **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними: - **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними:
столько экранов у цели [web-access](../../tasks/items/web-access.md). Роутеры столько экранов заводит задача
[spa-skeleton](../../tasks/items/spa-skeleton.md). Роутеры
`svelte-spa-router` 5.1.1, `vue-router` 5.2.0 и 4.6.4, `react-router` 8.3.0. `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 в - **Размеры** — `stat -c%s` и `gzip -9c | wc -c` по файлам `dist/`. Числа Vite в
своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят
+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`; при
подъёме версии библиотеки их отказ читается как сигнал перечитать эту записку, а
не как случайный шум.
+107 -47
View File
@@ -2,11 +2,34 @@
## Как настроен конвейер ## Как настроен конвейер
Конвейер ревью прогонялся один раз — 2026-08-11, на изменении Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/` — под именем
`fix-http-handler-tests`; его триаж лежит в `triage.md` либо `report.md`: имя менялось по ходу, и оба встречаются. Самый
`openspec/changes/archive/2026-08-11-fix-http-handler-tests/review/triage.md`. ранний — `fix-http-handler-tests` 2026-08-11, самый поздний —
Разделы ниже заполнены наперёд по коду и правятся по итогам прогонов: «Типовые `start-without-telegram-token` 2026-08-13.
ложноположительные» первым прогоном уже пользовались.
Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта
в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже.
Разделы ниже заведены наперёд по коду 2026-08-11 и с тех пор правятся урожаем
прогонов.
**Проход, поднявший сервис, обязан его остановить.** Живой прогон стал доступен
2026-08-13 (см. «Недоступно проверке»), и первый же им воспользовался: враждебный
проход поднял сервис на своём порту и оставил работать. Следующий прогон занять
порт не смог, а его запросы молча ушли к чужому процессу — то есть замеры
относились к прежней сборке, и по ним едва не был объявлен исход. Отсюда два
правила, оба прозой и без механизации: **поднял — останови за собой**, а
**меряющий убеждается, что отвечает его собственная сборка** (порт занят им,
новое поведение видно в выводе). Признак дешёвый: если ожидаемого нового поля,
метрики или строки нет вовсе — вероятнее всего, отвечает не твой процесс.
**Ни один проход не сообщает свой потолок, и это надо читать как границу
покрытия.** Прогон `telegram-enabled-flag` 2026-08-13: у прохода есть потолок
находок, и устав велит объявлять строкой, сколько осталось за срезом и какого
рода. Ни один из четырёх проходов такой строки не дал, и заметил это только
триаж. Пока так, «находок больше нет» в отчёте прохода неотличимо от «больше не
поместилось». Выше прочих риск у прохода, вбирающего темы разом: у него одна
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
остаётся сверка триажа.
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего [conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
@@ -68,22 +91,11 @@
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который - изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
становится неотличим от привычного шума (журнал, запись 2026-08-10); становится неотличим от привычного шума (журнал, запись 2026-08-10).
- проверка **способна упасть**. Утверждение, разбирающее ответ в ту же
структуру, чьи теги и составляют проверяемый контракт, меняется вместе с ним Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни
и никогда не ловит поломку; такое судят по сырому виду ответа. Признак ищется мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его
мутацией: сломай проверяемое свойство и убедись, что тест краснеет (журнал, границы — [CLAUDE.md](../CLAUDE.md), «Запреты».
запись 2026-08-11);
- **то же и об оракуле критерия приёмки, не только о тесте.** Критерий, чей
единственный оракул — молчание линтера, годится ровно тогда, когда линтер
краснеет на **всех** негодных реализациях; проверяется той же мутацией.
Прецедент: «отказ `Close` не теряется молча» принимался молчанием `errcheck`,
а тот пропускал `_ = conn.Close()` — реализацию, теряющую отказ целиком
(журнал, запись 2026-08-11 про недостижимую норму; закрыто
[решением](adr/ADR-2026-08-11-errcheck-check-blank.md));
- **требование без сценария не имеет оракула** и потому не может быть нарушено
заметно. Норма, которую нечем уронить, расходится с кодом молча — и расходится
тем вернее, чем убедительнее написана (журнал, запись 2026-08-11).
### Типовые ложноположительные ### Типовые ложноположительные
@@ -114,7 +126,7 @@
### Вопросы по темам ### Вопросы по темам
Форма: `<тема>: <вопрос> (<провенанс>)`. Форма: `<тема>: <вопрос> (<откуда>)`.
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до - `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
внешнего собеседника и это держат правила `noctx` и `contextcheck` внешнего собеседника и это держат правила `noctx` и `contextcheck`
@@ -122,7 +134,7 @@
собеседник»), а исход прерванного шага нормой по-прежнему не описан собеседник»), а исход прерванного шага нормой по-прежнему не описан
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что (`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
`transcribe.go`, 2026-08-13; прежний провенанс 2026-08-10 устарел вместе с `transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
дефектом «остановка хоронила запись»). дефектом «остановка хоронила запись»).
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у - `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**: одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
@@ -144,13 +156,16 @@
`createTranscribeJob` — сегодня через него идут оба входа `createTranscribeJob` — сегодня через него идут оба входа
([architecture.md](architecture.md), «Единые точки проекта»). ([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки — - `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
заведены две capability (`openspec/specs/intake` и `openspec/specs/pipeline`), заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и
и каждая описана частично. Поведение прочих узлов живёт в обзоре под маркерами первые две описаны частично. Поведение прочих узлов, включая
долга, а соблазн дописать туда ещё — самый большой. приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
ещё — самый большой.
- `conventions`: новая колонка правится во всех четырёх местах репозитория - `conventions`: новая колонка правится во всех четырёх местах репозитория
(CLAUDE.md, «Инварианты»). (CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня - `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
тестов два файла, и оба мимо конвейера. тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
числа файлов здесь не называем — оно протухает с каждой задачей.
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому - `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
@@ -186,8 +201,10 @@
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и - вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
пользователь приложения, до начала работы назвать нельзя; пользователь приложения, до начала работы назвать нельзя;
- всё, что делается на выбранном фреймворке впервые: форма решения нащупывается - всё, что делается на выбранном фреймворке впервые: правила
по ходу, пока конвенция веб-UI пуста; [conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
на пробном экране, а не из написанного кода, и первая же задача проверяет их
собой — форма решения нащупывается по ходу;
- установка на телефон: service worker перехватывает запросы, и что он кэширует, - установка на телефон: service worker перехватывает запросы, и что он кэширует,
до работы назвать нельзя; до работы назвать нельзя;
- работа с записями в несколько часов: потолки внешних сервисов не замерены, - работа с записями в несколько часов: потолки внешних сервисов не замерены,
@@ -199,7 +216,7 @@
- правка текста, который видит пользователь Telegram; - правка текста, который видит пользователь Telegram;
- новая метрика в `internal/metrics`; - новая метрика в `internal/metrics`;
- правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля; - правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона. - правка документов канона.
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
@@ -231,20 +248,60 @@ API и имя не откатываются обратной правкой по
длительность от подставного источника. Своего теста у длительность от подставного источника. Своего теста у
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в `adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md); [adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер - **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
Telegram: он проверяет токен обращением к Telegram, а боевым токеном теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом
запускаться запрещено. Значит поведенческая верификация живым прогоном (`openspec/specs/intake`, «Признак включения решает, поднимается ли вход
недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен
хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком»
нет. снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона
сменился с пустого ключа доступа на выключенный вход задачей
`telegram-enabled-flag` того же дня.
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram,
расшифровку и заливку.
## Журнал дефектов ## Журнал дефектов
Верхняя запись найдена конвейером ревью на первом же его прогоне, вторая — Записи новые сверху. `[пойман ревью]` — дефект нашёл прогон конвейера,
прогоном гейта при заведении канона 2026-08-10, две нижние восстановлены по `[пойман сканером]` — тест-сканер `internal/archrules`, `[проскочил]` — дефект
истории git тогда же. Три нижние помечены `проскочил`: ревью тогда не было, и уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
выдумывать его задним числом нельзя. оракул, и выдумывать оракул задним числом нельзя.
## 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 — остановка сервиса хоронила конвертируемую запись [пойман ревью] ## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
@@ -297,7 +354,7 @@ API и имя не откатываются обратной правкой по
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
неверной. Строка лога существовала всё это время, но проза о ней не знала, а неверной. Строка лога существовала всё это время, но проза о ней не знала, а
машина прозу не проверяет машина прозу не проверяет
- **Что меняем:** чистка перенесена с места употребления на **границу клиента** - **Что меняем:** чистку перенесли с места употребления на **границу клиента**
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в `internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
@@ -351,9 +408,10 @@ API и имя не откатываются обратной правкой по
которого писали. Мутация была, но одна — нужна была по одной на каждую форму которого писали. Мутация была, но одна — нужна была по одной на каждую форму
- **Что меняем:** правило судит по типу приёмника (`analyze-types`, - **Что меняем:** правило судит по типу приёмника (`analyze-types`,
`httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм; `httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
проверено мутацией по каждой. Отсюда же строка в проверено мутацией по каждой. Урок записи: запрет по имени, обходимый лишней
docs/conventions/go-linters.md, «Лестница механизации»: запрет по имени, обходимый лишней строкой, — это ступень строкой, свойства не держит — такому свойству нужен тест-сканер. Строка об этом
тест-сканера, наряженная запретом стояла в `docs/conventions/go-linters.md`, разделе «Лестница механизации»;
раздел снят 2026-08-13, урок остался здесь
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью] ## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
@@ -465,7 +523,9 @@ API и имя не откатываются обратной правкой по
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`, (`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали `Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
согласованно, и парная сверка не увидела бы документ, разошедшийся с согласованно, и парная сверка не увидела бы документ, разошедшийся с
согласованным кодом. Норма — capability `toolchain`. согласованным кодом. Нормативного дома у шага не осталось: спека `toolchain`
упразднена 2026-08-13, тогда же снесены и его двадцать сценариев — норма живёт
комментариями в самом скрипте.
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью] ## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
+35 -10
View File
@@ -122,8 +122,10 @@ Telegram отправителю.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят - **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`, `/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то `/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
есть доступны они только владельцу панели; проверено прогоном — записи отдают есть доступны они только владельцу панели; коды, снятые прогоном,
`403`, служебные разделы `401`. [database.md](database.md), «Коллекции», норма —
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
то, что заказано».
Целевой периметр добавляет сюда три вещи, и все три — от новых задач: Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
@@ -143,9 +145,10 @@ Telegram отправителю.
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
меняется владельцем в любой момент: список привязан к изменяемому значению. меняется владельцем в любой момент: список привязан к изменяемому значению.
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется - **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
кукой `transcriber_session`, живёт семь суток, обесценивается выходом. кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
([database.md](database.md), «Настройки с числовым значением»).
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
бессрочно, и семисуточный срок — единственное, чем отзыв доступа у провайдера бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера
доходит до сервиса, — не значил бы ничего. доходит до сервиса, — не значил бы ничего.
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
та же проверка, но она названа здесь отдельно, потому что это второй способ та же проверка, но она названа здесь отдельно, потому что это второй способ
@@ -271,13 +274,34 @@ Telegram отправителю.
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание `…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их (`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
скачивания и отправки пишутся в журнал. Теперь адрес снимается на границе скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает
клиента`internal/adapter/telegram`, `NewBot`: свой `Do` чистит отказ, а свой `Do``internal/adapter/telegram`, `NewBot`: он чистит отказ, а
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md), отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`. случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до
неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из
шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента
**раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ
конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами
независимо; оракул — там же, в `bot_test.go`.
Третий путь закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире
токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ
пересобирается своими словами: путь, строка, столбец и последний ключ, без текста
библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому
проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же
работе. Правило — [conventions/config.md](conventions/config.md), «Секреты»;
оракулы — `internal/config/config_test.go`, проверки поломанного файла настроек.
Остаточный риск назван там же: разрез опирается на то, какое семейство отказов
несёт значения **в нынешней версии** библиотеки.
## Что вне модели ## Что вне модели
Перечислить явно. Перечислить явно.
@@ -295,10 +319,11 @@ Telegram отправителю.
не замер: распределения длин у сервиса нет, а самая длинная проверенная запись не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
— 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)). — 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
нет и не будет: решено считать расход и показывать его владельцу, а не нет — это граница домена, [passport.md](passport.md), «Учёт денег»; расход
отказывать (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в считают `usage-accounting` и `admin-stats-screen`. Для модели угроз отсюда
Authelia. Рост каталога данных при этом ничем не наблюдается — следует одно: ни числом запросов, ни размером записи вошедший не ограничен, и
открытый вопрос `architecture.md`. защищаться от исчерпания диска мы не пытаемся. Рост каталога данных при этом
ничем не наблюдается — открытый вопрос `architecture.md`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель - **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
оплачиваются по факту; потолка на пользователя нет по тому же решению. оплачиваются по факту; потолка на пользователя нет по тому же решению.
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан - **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
+2 -1
View File
@@ -1,6 +1,6 @@
module git.vakhrushev.me/av/transcriber module git.vakhrushev.me/av/transcriber
go 1.26.0 go 1.26.6
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
@@ -49,6 +49,7 @@ require (
github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-isatty v0.0.23 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@@ -8,8 +8,9 @@
// //
// Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина // Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина
// внешняя: сверка документов ловит изменённый шаг схемы при нетронутом // внешняя: сверка документов ловит изменённый шаг схемы при нетронутом
// `docs/database.md` по префиксу пути (`docs/.docs.json`, ключ `migrations`), а // `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
// префикс наводится только на каталог. Пока шаги лежали файлом, наводить его // `[docs]`), а префикс наводится только на каталог. Пока шаги лежали файлом,
// наводить его
// было не на что, и проверка молчала на всякой правке схемы. // было не на что, и проверка молчала на всякой правке схемы.
package migrations package migrations
+27
View File
@@ -0,0 +1,27 @@
package telegram
import (
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// AbsentMessageSender подставляется вместо отправителя Telegram, когда вход
// выключен признаком `telegram.enabled` либо Telegram оказался недоступен, и
// клиента заводить не из чего. Он ничего не отправляет и на всякий ответ отдаёт
// `contract.ErrDeliveryChannelDown`.
//
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
// месте употребления завела бы в ядре знание о том, как собран сервис.
//
// Молчит он намеренно. Записать недоставку заглушке нечем: контракт отправки
// несёт текст, чат и сообщение для ответа, а идентификатора задачи в нём нет.
// Пишет поэтому шаг конвейера, который задачу знает.
type AbsentMessageSender struct{}
func NewAbsentMessageSender() *AbsentMessageSender {
return &AbsentMessageSender{}
}
func (s *AbsentMessageSender) Send(_ string, _ int64, _ *int) error {
return contract.ErrDeliveryChannelDown
}
+37
View File
@@ -0,0 +1,37 @@
package telegram
import (
"log/slog"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// Заглушка отдаёт «канал не поднят» и молчит: записать недоставку ей нечем —
// идентификатора задачи контракт отправки не несёт, и пишет её шаг конвейера.
func TestAbsentSenderReportsChannelDown(t *testing.T) {
sender := NewAbsentMessageSender()
err := sender.Send("расшифровка записи", 100, nil)
require.ErrorIs(t, err, contract.ErrDeliveryChannelDown)
}
// Непустой годный токен по-прежнему даёт настоящего отправителя: прежний путь
// сохранён, и меняется только то, что клиента теперь отдают готовым.
func TestSenderIsBuiltFromLiveBot(t *testing.T) {
bot, _ := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
})
sender := NewTelegramMessageSender(bot, slog.New(slog.DiscardHandler))
require.NotNil(t, sender)
assert.Same(t, bot, sender.bot, "отправитель говорит с тем же клиентом, что и транспорт")
}
+36 -3
View File
@@ -7,12 +7,19 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
) )
// ErrEmptyToken — токен бота не задан. Отдельным значением, потому что подъём // ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка
// без Telegram — законный исход: сервис продолжает работать с HTTP API. // настройки**: старт роняется. Отдельным значением, чтобы отличаться от
// недоступности Telegram, у которой исход обратный — подъём без бота.
//
// Отказ от входа Telegram этим значением больше не выражается: намерение
// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается
// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше,
// поэтому сюда он доходит только в обход проверки.
var ErrEmptyToken = errors.New("telegram bot token is empty") var ErrEmptyToken = errors.New("telegram bot token is empty")
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с // NewBot заводит клиента Bot API — и это **единая точка**, через которую с
@@ -43,9 +50,35 @@ func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, erro
return nil, fmt.Errorf("failed to set telegram logger: %w", err) return nil, fmt.Errorf("failed to set telegram logger: %w", err)
} }
return tgbotapi.NewBotAPIWithClient(token, endpoint, &safeClient{inner: &http.Client{}}) // Сборка ходит за `getMe` и стоит на пути старта — раньше HTTP-сервера,
// панели и воркеров. Без срока ожидания молчащий Telegram (соединение
// принято, ответа нет) вешал бы весь подъём бессрочно: порт не слушается,
// проба здоровья не отвечает, а в журнале ни строки.
probe := &safeClient{inner: &http.Client{Timeout: ProbeTimeout}}
// Отказ конструктора чистится здесь, а не клиентом: адрес собирается
// строкой с токеном внутри, и `http.NewRequest` падает на его разборе
// **до** обращения к клиенту — то есть мимо `safeClient`. Токен с
// управляющим символом или неверной `%`-последовательностью иначе уезжает
// в журнал целиком: перенос строки в конце значения ловится так же.
bot, err := tgbotapi.NewBotAPIWithClient(token, endpoint, probe)
if err != nil {
return nil, WithoutURL(err)
} }
// Дальше живёт длинный опрос, и срок ему не нужен: он ждёт обновлений
// столько, сколько задано настройкой, и клиент со сроком рвал бы его.
bot.Client = &safeClient{inner: &http.Client{}}
return bot, nil
}
// ProbeTimeout — сколько ждём Telegram при сборке клиента. Число выбрано
// решением, а не замером: одно обращение за `getMe` укладывается в доли
// секунды, а десять секунд — потолок, после которого Telegram считается
// недоступным и сервис поднимается без него.
const ProbeTimeout = 10 * time.Second
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет // safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему // зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API. // нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
+32
View File
@@ -63,6 +63,27 @@ func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
}) })
} }
// Токен, ломающий разбор адреса, — второй путь отказа конструктора, и до
// недавнего он был открыт: `http.NewRequest` падает раньше обращения к клиенту,
// то есть мимо чистки на его границе. Так выглядит перенос строки, приехавший
// с секретом из шаблона выкладки, и невычищенная `%`-последовательность.
func TestBotConstructionFailureOnUnparsableTokenDoesNotCarryToken(t *testing.T) {
broken := map[string]string{
"перенос строки": probeToken + "\n",
"негодная escape-пара": "7654321:AAH%zzSECRETtokenVALUE",
}
for name, token := range broken {
t.Run(name, func(t *testing.T) {
_, err := newBot(token, tgbotapi.APIEndpoint, slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.NotContains(t, err.Error(), token, "токен уехал в отказ: %v", err)
assert.NotContains(t, err.Error(), "api.telegram.org", "адрес остался в отказе: %v", err)
})
}
}
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`, // Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду. // и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) { func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
@@ -92,10 +113,21 @@ func TestLibraryLoggerRedactsToken(t *testing.T) {
} }
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу. // Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
// Обратное тоже нормируется: отказ негодного токена не должен читаться как
// отказ от входа, иначе сборка при старте подставит заглушку там, где нужен
// отказ, и молча потеряет бота.
func TestEmptyTokenIsRecognizedByValue(t *testing.T) { func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
_, err := NewBot("", slog.New(slog.DiscardHandler)) _, err := NewBot("", slog.New(slog.DiscardHandler))
require.ErrorIs(t, err, ErrEmptyToken) require.ErrorIs(t, err, ErrEmptyToken)
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close()
_, err = newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.Error(t, err)
require.NotErrorIs(t, err, ErrEmptyToken)
} }
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает // WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
+6 -9
View File
@@ -15,18 +15,15 @@ type TelegramMessageSender struct {
logger *slog.Logger logger *slog.Logger
} }
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) { // NewTelegramMessageSender принимает готового клиента, а не токен. Клиента
// Клиент заводится единой точкой: её отказ не несёт токена, а отказ // заводит сборка при старте — одного на отправителя и на транспорт бота: пока
// конструктора несёт — `NewBotAPI` зовёт `getMe`. // его строили здесь и там порознь, два пути одного старта разошлись в том,
bot, err := NewBot(botToken, logger) // терпеть ли негодный токен, и согласовывать их приходилось руками.
if err != nil { func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender {
return nil, err
}
return &TelegramMessageSender{ return &TelegramMessageSender{
bot: bot, bot: bot,
logger: logger, logger: logger,
}, nil }
} }
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error { func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
+65 -2
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
@@ -41,11 +42,33 @@ type YandexConfig struct {
ObjStorageEndpoint string `toml:"object_storage_endpoint"` ObjStorageEndpoint string `toml:"object_storage_endpoint"`
} }
// TelegramConfig — вход Telegram. Признак включения объявляет намерение
// владельца, `BotToken` означает только доступ. Пока два значения жили в одном
// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал»,
// и сервис поднимался без бота в обоих случаях.
type TelegramConfig struct { type TelegramConfig struct {
// Enabled — умолчания у него нет **намеренно**, и потому его нет в
// `defaultConfig()`: умолчание было бы угаданным намерением, а признак
// заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит
// `LoadConfig` — нулевое значение `bool` режима не выбирает.
Enabled bool `toml:"enabled"`
BotToken string `toml:"bot_token"` BotToken string `toml:"bot_token"`
UpdateTimeout int `toml:"update_timeout"` UpdateTimeout int `toml:"update_timeout"`
} }
// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ
// при включённом входе — ошибка настройки: бот по нему не появится, а тихий
// подъём без бота оставил бы отправителей без ответов.
//
// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не
// должно.
func (c TelegramConfig) Validate() error {
if c.Enabled && c.BotToken == "" {
return errors.New("telegram: не заполнен ключ bot_token при enabled = true")
}
return nil
}
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор // AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции // клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и // пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
@@ -129,6 +152,7 @@ func defaultConfig() *Config {
ObjStorageRegion: "ru-central1", ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/", ObjStorageEndpoint: "https://storage.yandexcloud.net/",
}, },
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
Telegram: TelegramConfig{ Telegram: TelegramConfig{
BotToken: "", BotToken: "",
UpdateTimeout: 10, UpdateTimeout: 10,
@@ -149,9 +173,48 @@ func LoadConfig(path string) (*Config, error) {
config := defaultConfig() config := defaultConfig()
// Load configuration from file // Load configuration from file
if _, err := toml.DecodeFile(path, &config); err != nil { meta, err := toml.DecodeFile(path, &config)
return nil, fmt.Errorf("failed to decode config file: %w", err) if err != nil {
return nil, decodeError(path, err)
}
// Признак включения входа Telegram обязателен: умолчания у него нет, и
// отличить «не задан» от «задан ложным» умеет только разбор — нулевое
// значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу
// она не отдаётся, приговор выносится здесь.
if !meta.IsDefined("telegram", "enabled") {
return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram")
} }
return config, nil return config, nil
} }
// decodeError переводит отказ разбора на свои слова. Пересказывать библиотеку
// нельзя: она собирает текст отказа из разбираемого куска файла, и оборванная
// строка секретного ключа уехала бы в журнал вместе со значением.
//
// Разрез идёт по семейству отказа, и значения несёт только одно:
//
// - `toml.ParseError` — сюда сведены отказы лексера и разбора значения, а его
// `Message` собран из разбираемого куска («Invalid float value: %q»,
// «invalid duration: %q»). Берём строку, столбец и последний ключ — они
// безопасны, — а `Message` не берём;
// - прочие отказы декодера собраны из имён ключей и имён типов, значений в них
// нет вовсе. Их текст берём как есть: выбросив его, мы заплатили бы
// разборчивостью отказа там, где платить не за что.
//
// Две ветки не сводятся в одну намеренно. Сведённая к общему знаменателю, она
// либо вернёт утечку, либо оставит несовпадение типов без единого намёка.
func decodeError(path string, err error) error {
var parseErr toml.ParseError
if errors.As(err, &parseErr) {
if parseErr.LastKey != "" {
return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d, последний ключ %q",
path, parseErr.Position.Line, parseErr.Position.Col, parseErr.LastKey)
}
return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d",
path, parseErr.Position.Line, parseErr.Position.Col)
}
return fmt.Errorf("failed to decode config file %s: %w", path, err)
}
+179
View File
@@ -1,6 +1,9 @@
package config package config
import ( import (
"fmt"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
) )
@@ -95,3 +98,179 @@ func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
}) })
} }
} }
// Признак включения объявляет намерение, ключ доступа означает только доступ.
// Пока эти два значения жили в одном поле, пустой токен читался разом как
// «вход выключен» и как «ключ не доехал».
func TestTelegramConfigValidateAcceptsEnabledWithToken(t *testing.T) {
cfg := TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
if err := cfg.Validate(); err != nil {
t.Fatalf("включённый вход с ключом отвергнут: %v", err)
}
}
func TestTelegramConfigValidateRejectsEnabledWithoutToken(t *testing.T) {
cfg := TelegramConfig{Enabled: true, BotToken: ""}
err := cfg.Validate()
if err == nil {
t.Fatal("включённый вход без ключа доступа пропущен")
}
if !strings.Contains(err.Error(), "bot_token") {
t.Fatalf("имя ключа не названо: %v", err)
}
}
// Выключенный вход на ключ доступа не смотрит вовсе: пустой ключ при нём —
// обычное состояние локального прогона, а не ошибка настройки.
func TestTelegramConfigValidateIgnoresTokenWhenDisabled(t *testing.T) {
cfg := TelegramConfig{Enabled: false, BotToken: ""}
if err := cfg.Validate(); err != nil {
t.Fatalf("выключенный вход без ключа отвергнут: %v", err)
}
}
// Половина требования «сообщение не несёт значения ключа» на этой проверке
// **вакуумна**, и честнее это назвать, чем изображать сторожа.
//
// `Validate()` отказывает ровно на пустом ключе — значения, которым можно
// проговориться, на этом пути не существует. Прежняя редакция сторожа искала
// подстроку, которой в сообщении нет ни при каком входе, и потому не могла
// упасть вовсе: правка на `%q` от токена оставила бы её зелёной. В проекте это
// третий пойманный случай проверки, не способной упасть.
//
// Настоящий сторож той же нормы живёт там, где непустой ключ в отказ попасть
// действительно может, — `TestLoadConfigMalformedSecretLineHidesValue` и
// `TestLoadConfigMalformedBeforeAnyKeyHidesValue`. Здесь проверяется то, что
// проверяемо: заполненный ключ проходит, пустой отвергается с именем ключа.
func TestTelegramConfigValidateNamesKeyWithoutValue(t *testing.T) {
filled := TelegramConfig{Enabled: true, BotToken: "123456:AAHfake-secret-token-value"}
if err := filled.Validate(); err != nil {
t.Fatalf("включённый вход с заполненным ключом отвергнут: %v", err)
}
err := TelegramConfig{Enabled: true, BotToken: ""}.Validate()
if err == nil {
t.Fatal("включённый вход без ключа доступа пропущен")
}
if !strings.Contains(err.Error(), "bot_token") {
t.Fatalf("имя ключа не названо: %v", err)
}
}
func writeConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("не удалось записать файл настроек: %v", err)
}
return path
}
const validConfigBody = `
[telegram]
enabled = false
bot_token = ""
`
// Признак обязателен: файл без него негоден. Умолчание было бы угаданным
// намерением, а отличить «не задан» от «задан ложным» умеет только разбор —
// нулевое значение bool у обоих одинаковое.
func TestLoadConfigRejectsMissingTelegramEnabled(t *testing.T) {
path := writeConfig(t, "[telegram]\nbot_token = \"123456:AA-fake\"\n")
_, err := LoadConfig(path)
if err == nil {
t.Fatal("файл без признака включения принят")
}
if !strings.Contains(err.Error(), "enabled") {
t.Fatalf("имя недостающего ключа не названо: %v", err)
}
}
func TestLoadConfigReadsBothValuesOfTelegramEnabled(t *testing.T) {
for _, enabled := range []bool{true, false} {
t.Run(fmt.Sprintf("%t", enabled), func(t *testing.T) {
body := fmt.Sprintf("[telegram]\nenabled = %t\nbot_token = \"123456:AA-fake\"\n", enabled)
cfg, err := LoadConfig(writeConfig(t, body))
if err != nil {
t.Fatalf("годный файл отвергнут: %v", err)
}
if cfg.Telegram.Enabled != enabled {
t.Fatalf("признак доехал как %t, а в файле %t", cfg.Telegram.Enabled, enabled)
}
})
}
}
// Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая
// библиотека из разбираемого куска файла, и оборванная строка ключа доступа
// уехала бы в журнал вместе со значением. Отсюда собственное сообщение.
func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
const secret = "123456:AAHfake-secret-token-value"
// Кавычка не закрыта: разбор оборвётся на значении.
path := writeConfig(t, "[telegram]\nenabled = true\nbot_token = \""+secret+"\n")
_, err := LoadConfig(path)
if err == nil {
t.Fatal("поломанный файл настроек принят")
}
message := err.Error()
for _, part := range []string{secret, "AAHfake", "secret-token-value", "123456"} {
if strings.Contains(message, part) {
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
}
}
// Без места и ключа отказ нечинибелен: скрыть значение мало.
if !strings.Contains(message, "строке 3") {
t.Fatalf("номер строки не назван, чинить нечего: %v", err)
}
if !strings.Contains(message, "bot_token") {
t.Fatalf("ключ не назван, чинить нечего: %v", err)
}
}
// Обратная сторона того же разреза: отказ несовпадения типов собран из имён
// ключей и типов, значений в нём нет, и выбрасывать его текст незачем.
func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) {
path := writeConfig(t, "[server]\nport = \"8080\"\n"+validConfigBody)
_, err := LoadConfig(path)
if err == nil {
t.Fatal("строка вместо числа принята")
}
if !strings.Contains(err.Error(), "port") {
t.Fatalf("имя ключа не названо, чинить нечего: %v", err)
}
}
// Вторая ветка разреза: разбор оборвался до всякого ключа, и последнего ключа
// нет вовсе. Пропущенная скобка секции — обычная опечатка, а ветка эта самая
// уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки
// обратно.
func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
const secret = "123456:AAHsecret-token-value"
// У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа.
path := writeConfig(t, "[telegram\nenabled = true\nbot_token = \""+secret+"\"\n")
_, err := LoadConfig(path)
if err == nil {
t.Fatal("поломанный файл настроек принят")
}
message := err.Error()
for _, part := range []string{secret, "AAHsecret", "secret-token-value"} {
if strings.Contains(message, part) {
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
}
}
if !strings.Contains(message, "строке") {
t.Fatalf("место отказа не названо, чинить нечего: %v", err)
}
}
+13 -1
View File
@@ -1,6 +1,18 @@
package contract package contract
import "fmt" import (
"errors"
"fmt"
)
// ErrDeliveryChannelDown — канал, которым отвечают отправителю, не поднят.
// Отдаётся отправителем-заглушкой, которого получает ядро, когда вход не
// настроен.
//
// Значение сентинельное, а не тип: соседям по ряду есть что нести — состояние,
// идентификатор задачи, — а этому нечего. Заглушка не знает ни задачи, ни чата,
// и запись о недоставке делает шаг, у которого задача под рукой.
var ErrDeliveryChannelDown = errors.New("delivery channel is down")
type JobNotFoundError struct { type JobNotFoundError struct {
State string State string
-6
View File
@@ -328,9 +328,3 @@ func (c *TelegramController) isAudioDocument(document *tgbotapi.Document) bool {
return false return false
} }
type EmptyBotTokenError struct{}
func (e *EmptyBotTokenError) Error() string {
return "telegram bot token is empty"
}
+22
View File
@@ -44,6 +44,28 @@ var (
[]string{"source_format", "target_format", "error"}, []string{"source_format", "target_format", "error"},
) )
// Поднят ли вход приёма. Единственный канал наблюдения, автоматизированный
// у владельца: потерянный вход иначе виден только строкой журнала при
// старте, а проба здоровья отвечает «ok» и без него.
IntakeUpGauge = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "transcriber_intake_up",
Help: "Whether an intake channel is up (1) or not (0)",
},
[]string{"channel"},
)
// Ответы, которые не удалось доставить отправителю. Работа при этом
// сделана, шаг отказа не объявляет, и без счётчика недоставка видна только
// в журнале — до его ротации.
UndeliveredReplyCounter = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "transcriber_undelivered_reply_count",
Help: "Count of replies that could not be delivered to the sender",
},
[]string{"reason"},
)
// Размер файла после конвертации (в байтах) // Размер файла после конвертации (в байтах)
OutputFileSizeHistogram = promauto.NewHistogramVec( OutputFileSizeHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{ prometheus.HistogramOpts{
+53 -4
View File
@@ -551,19 +551,43 @@ func (s *TranscribeService) failJob(job *entity.TranscribeJob, holder string, jo
return s.send(job, errorMessage) return s.send(job, errorMessage)
} }
// send отвечает отправителю там, откуда пришла запись, и отказ отправки // send отвечает отправителю там, откуда пришла запись. Отказ отправки поднимает
// поднимает вверх: он принадлежит шагу. // вверх: он принадлежит шагу.
//
// Кроме недоставки — её шаг записывает и завершается без отказа. Ответ уходит
// после того, как достигнутое состояние сохранено: работа к этой минуте
// сделана, и объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу
// записью отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не
// появятся, — поэтому причина недоставки живёт в журнале, а не в состоянии
// задачи.
//
// Служебные поля завершённой задачи отказ бы при этом не переписал: переход в
// терминальное состояние снимает захват, и повторная запись натыкается на
// «захват потерян». Довод держится на счётчике и журнале, а не на этом.
func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error { func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
if job.Source != entity.SourceTelegram { if job.Source != entity.SourceTelegram {
return nil return nil
} }
// Адресата у задачи нет: отвечать некуда, и повторять нечего. Уровень здесь
// выше, чем у неподнятого канала, и это не педантизм: пустой чат у задачи
// из Telegram — симптом порчи записи, а самый коварный её источник назван
// инвариантом «колонки очереди правятся в четырёх местах». Утони этот
// сигнал в одном ряду со штатным «бот не настроен» — и обнуление колонки
// заметит только отправитель, переставший получать ответы.
if job.TgChatId == nil { if job.TgChatId == nil {
s.logger.Error("Telegram chat not specified", "job_id", job.Id) s.undelivered(job, slog.LevelError, "chat is not specified")
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id) return nil
} }
if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil { if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil {
// Канал не поднят: сервис работает без этого входа, и это объявленный
// режим, а не поломка.
if errors.Is(err, contract.ErrDeliveryChannelDown) {
s.undelivered(job, slog.LevelWarn, "delivery channel is down")
return nil
}
s.logger.Error("Failed to sent message to client", "job_id", job.Id) s.logger.Error("Failed to sent message to client", "job_id", job.Id)
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err) return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
} }
@@ -571,6 +595,31 @@ func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
return nil return nil
} }
// undelivered записывает недоставленный ответ и считает его в метрику. Уровень
// приходит от причины: объявленный режим — «может стать проблемой», порча
// записи — событие для разбора.
//
// Идентификатор задачи обязателен, иначе владелец видит, что ответ не ушёл, но
// не может найти, чей; текста ответа в записи нет — он содержимое чужой записи.
//
// Счётчик нужен потому, что журнал контейнера живёт до ротации, а вопрос «кому
// не ответили за последние сутки» задают позже.
func (s *TranscribeService) undelivered(job *entity.TranscribeJob, level slog.Level, reason string) {
metrics.UndeliveredReplyCounter.WithLabelValues(reason).Inc()
// Уровень выбирается ветвлением, а не передачей контекста: контекст здесь
// брать неоткуда — ответ идёт после сохранения состояния, — а выдуманный
// `context.Background()` соврал бы про отмену и цеплялся бы правилами.
switch level {
case slog.LevelError:
s.logger.Error(undeliveredMessage, "job_id", job.Id, "reason", reason)
default:
s.logger.Warn(undeliveredMessage, "job_id", job.Id, "reason", reason)
}
}
const undeliveredMessage = "Reply was not delivered"
// notify отвечает отправителю там, где поднимать отказ некуда: задача уже // notify отвечает отправителю там, где поднимать отказ некуда: задача уже
// доведена до конца, и отказ отправки остаётся записью в журнале владельца. // доведена до конца, и отказ отправки остаётся записью в журнале владельца.
func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) { func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) {
+140
View File
@@ -0,0 +1,140 @@
package service
import (
"bytes"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Ответ отправителю уходит после того, как достигнутое состояние сохранено.
// Значит, недоставка не может быть отказом шага: объявленный отказ засчитался
// бы воркеру сбоем, лёг бы владельцу записью отказа и переписал бы служебные
// поля завершённой задачи. Причин недоставки две, исход у них общий.
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
// которую ядро получает вместо отправителя Telegram.
type downSender struct {
calls int
}
func (s *downSender) Send(string, int64, *int) error {
s.calls++
return contract.ErrDeliveryChannelDown
}
// journalEnv пересобирает сервис с названным отправителем и своим журналом:
// утверждения судят и состояние задачи, и то, что увидел владелец.
func journalEnv(
t *testing.T,
env *pipelineEnv,
rec contract.AudioRecognizer,
sender contract.TelegramMessageSender,
) (*TranscribeService, *bytes.Buffer) {
t.Helper()
journal := &bytes.Buffer{}
svc := NewTranscribeService(
env.jobRepo,
env.fileRepo,
&okMetaViewer{},
&failingConverter{},
rec,
sender,
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
)
return svc, journal
}
// Канал не поднят: задача доводится до конца, шаг отказа не объявляет, а
// владелец узнаёт о недоставке из журнала.
func TestUndeliveredOnDownChannelKeepsJobDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
sender := &downSender{}
svc, journal := journalEnv(t, env, rec, sender)
// Шаг завершается без отказа — именно это воркер считает в свой счётчик.
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии")
require.NotNil(t, after.TranscriptionText)
assert.Equal(t, "расшифровка записи", *after.TranscriptionText, "расшифровка сохранена")
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
assert.Contains(t, written, job.Id, "запись несёт идентификатор задачи")
assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
assert.NotContains(t, written, "расшифровка записи", "текста расшифровки в журнале нет")
}
// Адресат у задачи не назван: исход тот же. Прежде эта ветка объявляла отказ
// шага на уже завершённой работе.
func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
// Задача из Telegram, у которой чат не назван: такую отдаёт правка в панели.
// Колонка чистится мимо захвата — иначе setup унёс бы задачу у шага.
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("tg_chat_id", nil)
require.NoError(t, env.app.Save(record))
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
sender := &downSender{}
svc, journal := journalEnv(t, env, rec, sender)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered")
assert.Contains(t, written, job.Id)
assert.Contains(t, written, "chat is not specified", "причина названа")
assert.Contains(t, written, "level=ERROR",
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
}
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
// строки о задачах основного входа.
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg")
require.NoError(t, err)
sender := &downSender{}
svc, journal := journalEnv(t, env, &scriptedRecognizer{}, sender)
require.NoError(t, svc.send(job, "расшифровка записи"))
assert.Equal(t, 0, sender.calls, "отправителя не звали")
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
}
+25 -27
View File
@@ -17,12 +17,11 @@ import (
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg" ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/config" "git.vakhrushev.me/av/transcriber/internal/config"
"git.vakhrushev.me/av/transcriber/internal/contract"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg" tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
"git.vakhrushev.me/av/transcriber/internal/controller/worker" "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/internal/service"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
@@ -60,6 +59,14 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Включённый вход без ключа доступа — ошибка настройки, а не режим: бот по
// пустому ключу не появится, а тихий подъём без него оставил бы отправителей
// без ответов.
if err := cfg.Telegram.Validate(); err != nil {
logger.Error("Unable to start with incomplete telegram settings", "error", err)
os.Exit(1)
}
// Загружаем переменные окружения из .env файла // Загружаем переменные окружения из .env файла
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
logger.Warn("Warning: .env file not found, using system environment variables") logger.Warn("Warning: .env file not found, using system environment variables")
@@ -89,9 +96,9 @@ func main() {
metaviewer := ffmpegmv.NewFfmpegMetaViewer() metaviewer := ffmpegmv.NewFfmpegMetaViewer()
converter := ffmpegconv.NewFfmpegConverter() converter := ffmpegconv.NewFfmpegConverter()
tgSender, err := telegram.NewTelegramMessageSender(cfg.Telegram.BotToken, logger) tgBot, tgSender, err := buildTelegram(cfg.Telegram, logger)
if err != nil { if err != nil {
logger.Error("failed to create audio telegram sender", "error", err) logger.Error("Failed to create Telegram bot", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -141,13 +148,17 @@ func main() {
UserWhiteList: cfg.Server.UsersWhiteList, UserWhiteList: cfg.Server.UsersWhiteList,
} }
// Клиента бота заводит единая точка: её отказ не несёт токена, тогда как // Транспорт поднимается только там, где есть клиент: о том, что бота нет,
// отказ `NewBotAPI` несёт — он ходит за `getMe`. // сказано выше единственной записью, и вторая здесь была бы записью о том
tgController, err := newTelegramController(cfg.Telegram.BotToken, tgConfig, transcribeService, jobRepo, logger) // же факте.
var tgController *tgcontroller.TelegramController
if tgBot != nil {
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, jobRepo, logger)
if err != nil { if err != nil {
logger.Error("Failed to create Telegram controller", "error", err) logger.Error("Failed to create Telegram controller", "error", err)
// Не останавливаем приложение, если Telegram бот не создан os.Exit(1)
} else { }
// Запускаем Telegram бот в отдельной горутине // Запускаем Telegram бот в отдельной горутине
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -179,6 +190,11 @@ func main() {
}(w) }(w)
} }
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него
// нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об
// обоих входах одним отбором.
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
// и второму серверу на нём взяться неоткуда. // и второму серверу на нём взяться неоткуда.
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger) transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
@@ -328,21 +344,3 @@ func main() {
logger.Info("Transcriber service stopped") logger.Info("Transcriber service stopped")
} }
// newTelegramController собирает бота и транспорт вокруг него. Токен доходит
// до единой точки `internal/adapter/telegram` и дальше не идёт: транспорт его
// не видит вовсе, а отказ, который увидит журнал, адреса с токеном не несёт.
func newTelegramController(
botToken string,
cfg tgcontroller.TelegramConfig,
transcribeService *service.TranscribeService,
jobRepo contract.TranscriptJobRepository,
logger *slog.Logger,
) (*tgcontroller.TelegramController, error) {
bot, err := telegram.NewBot(botToken, logger)
if err != nil {
return nil, err
}
return tgcontroller.NewTelegramController(cfg, bot, transcribeService, jobRepo, logger)
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-13
@@ -0,0 +1,207 @@
## Context
Сегодня старт роняет отсутствующий токен бота: сборка отправителя ответов
возвращает «токен не задан», и процесс заканчивается раньше, чем встаёт
HTTP-сервер. Соседний путь того же старта — сборка транспорта бота — тот же отказ
уже терпит и сервис не роняет. Два пути одного старта решают одно и то же
по-разному, и побеждает тот, что стоит выше.
Ограничение, из-за которого это дорого: боевым токеном запускаться запрещено, а
другого действующего токена у разработчика нет. Значит, живой прогон недоступен
никому, и всякая задача проверяется одними тестами.
Отправитель ответов уходит в ядро расшифровки обязательной зависимостью, и ядро
зовёт его без проверки. Убрать отправителя, ничего не решив, — значит уронить
процесс на первой же задаче из Telegram, лежащей в базе с прошлого запуска.
## Goals / Non-Goals
**Goals:**
- сервис поднимается без токена бота и работает оставшимся входом;
- отсутствие бота видно в журнале, а не выводится читателем из тишины;
- задача из Telegram, которой некому ответить, не роняет процесс и не теряется
молча;
- ошибка в токене остаётся заметной.
**Non-Goals:**
- приём из Telegram по существу — кто допущен, как забирается запись — не
нормируется; оговорка спеки `intake` остаётся;
- запрет запускаться боевым токеном не снимается и не смягчается;
- второй вход не становится необязательным «вообще»: сервис без обоих входов
бессмыслен, но проверять это изменение не берётся;
- отправка отложенных ответов, когда бот появится позже, не заводится.
## Decisions
### Решение 1: пустой токен — отказ от входа, негодный непустой — ошибка настройки
Разрез проходит по **пустому значению**, а не по отказу сборки бота.
Что человек увидит иначе: разработчик стирает токен в своём файле настроек и
поднимает сервис; владелец сервиса, опечатавшийся в токене при ротации, получает
отказ старта вместо сервиса, молча работающего без бота.
**Правка после ревью кода, решение владельца 2026-08-13.** Разрез перенесён с
«пусто / непусто» на «ответил ли Telegram»: недоступность Telegram на подъём
сервиса не влияет. Довод — тот же, что у паспорта: основной вход не Telegram, и
класть его целиком из-за чужой аварии нельзя. Ровно этого и требовал прежний
разрез: перезапуск в минуту аварии Bot API оставил бы без работы приём по HTTP,
панель и конвейер, которому Telegram не нужен вовсе.
Отдельно снят довод, оказавшийся ложным. Дизайн утверждал, что «Telegram не
признал бота» и «до Telegram не дошли» различать нечем. Различать есть чем:
ответ Bot API приезжает своим типом с кодом, транспортный отказ — нашим после
чистки, и одно от другого отделяется проверкой типа. Утверждение держалось на
незнании библиотеки, а не на её устройстве.
Из решения следует второе, без которого оно невыполнимо: **ожидание при сборке
ограничивается сроком**. Пока срока не было, недоступность не отличалась от
подъёма — молчащий Telegram вешал старт бессрочно, без записи, без порта и без
пробы здоровья. Срок стоит только на сборке; длинный опрос им не ограничен, и
клиент подменяется сразу после.
Рассмотрено и отвергнуто:
- **терпеть любой отказ сборки бота** — отвергнуто: опечатка в боевом токене
дала бы работающий сервис без бота, и отправители перестали бы получать
ответы. Ответ «такого бота нет» опознаётся точно, ждать по нему нечего, и он
остаётся единственным отказом старта;
- **ронять старт на любом отказе** — отвергнуто владельцем: авария третьей
стороны не должна класть основной вход;
- **отдельный ключ настройки «работать без Telegram»** — явное объявление
намерения. Отвергнуто: имя ключа конфига объявлено необратимым, а пустое
значение уже несёт ровно этот смысл. Второй способ сказать одно и то же
разъезжается — останется решить, что делать с пустым токеном при выключенном
ключе.
**Разрез стоит в одном месте, потому что клиент бота собирается один раз.**
Сегодня его собирают дважды — под отправителя ответов и под транспорт бота, — и
именно поэтому два пути разошлись. Вместо того чтобы согласовывать их вручную,
изменение сводит сборку к одной: клиент заводится в сборке при старте и отдаётся
обоим. Транспорт уже принимает готового клиента, так что менять надо только
отправителя — он перестаёт принимать токен и начинает принимать клиента.
Что это даёт сверх опрятности: разрез «пусто / непусто» существует ровно один,
запись о неподнятом боте по построению одна, обращение к Telegram при старте
одно вместо двух, и подмена журнала библиотеки тоже одна. Проверять «согласованы
ли два пути» больше не надо — второго пути нет.
**Цена решения:** сборка бота перестаёт терпеть негодный токен и начинает ронять
старт. Наблюдаемо это почти ничего не меняет: сборка отправителя роняет старт на
том же токене и сегодня, а стоит она раньше — до терпимости транспорта очередь
попросту не доходит.
### Решение 2: заглушка отвечает «канала нет», а запись делает шаг
Когда токена нет, ядро получает отправителя-заглушку. Она ничего не отправляет и
на всякий ответ возвращает **особое значение отказа — «канал доставки не
поднят»**. Шаг конвейера узнаёт это значение, пишет недоставку в журнал с
идентификатором задачи и завершается **без отказа**.
Что человек увидит иначе: владелец сервиса находит в журнале строку «ответ не
доставлен» с идентификатором задачи и забирает расшифровку там же, где лежат
остальные.
Почему запись делает шаг, а не сама заглушка: **идентификатора задачи у
заглушки нет**. Контракт отправки несёт текст, чат и сообщение для ответа —
задачу он не называет, и знать о ней отправителю незачем. Заглушка, пишущая
`chat_id` вместо задачи, дала бы владельцу строку, по которой задачу не найти, а
расширение контракта ради журнала потянуло бы правку и настоящего отправителя, и
всех его вызовов.
Почему это не заводит в ядре ветки «а есть ли бот»: ядро ветвится не на
устройстве сборки, а на **исходе доставки** — ровно так же, как оно уже ветвится
на «работы нет» и «захват потерян». Особое значение отказа живёт там же, где эти
два, и узнаётся тем же способом. Знания о том, как собран сервис, у ядра не
появляется.
Источник задачи ядро при этом уже различает: ответ отправителю начинается с
проверки источника и на задаче, пришедшей по HTTP, кончается раньше обращения к
отправителю. Заглушка задач основного входа не увидит, и ложных строк о
недоставке в журнале не будет.
Рассмотрено и отвергнуто:
- **ронять задачу в `failed`** — отвергнуто: расшифровка к этому моменту уже
получена и сохранена, а «не удалось» сообщить всё равно некому. Пометка отказа
на удавшейся работе врёт и панели, и метрике;
- **оставлять задачу пригодной к повтору** — отвергнуто: смысл повтора в том,
чтобы работа однажды удалась, а недоставка сама не пройдёт — бот не появится
оттого, что задачу подождали;
- **немая заглушка, возвращающая успех** — отвергнуто находкой ревью дизайна:
недоставку тогда некому записать, и норма «принятая запись не теряется молча»
оказывается нарушена именно тем решением, которое её и обслуживало;
- **отпустить отказ заглушки наверх, не разбирая** — отвергнуто: шаг объявил бы
отказ там, где работа сделана. Задачу это в повтор не отправит — воркеры
опрашивают только незавершённые состояния, — но воркеру засчитается сбой,
которого не было, и владельцу уедет запись отказа. Соврала бы и метрика, и
журнал.
### Решение 3: заглушка живёт рядом с настоящим отправителем
Место — тот же пакет, что и отправитель Telegram: заглушка знает ровно то же,
что и он, и подставляется в сборке при старте, как и все прочие адаптеры.
Направление зависимостей это не нарушает, и тесты-сканеры остаются зелёными.
Само значение отказа живёт среди контрактов — там же, где «работы нет» и «захват
потерян»: узнаёт его ядро, а порождает адаптер, и ни один из них не зависит от
другого.
**Форма значения — сентинел, а не тип с полями.** Соседи по ряду несут поле
(состояние, идентификатор задачи) и потому объявлены типами; этому нести нечего —
заглушка не знает ни задачи, ни чата. Прецедент сентинела в проекте есть: им же
объявлено «токен не задан».
**Заодно убирается третье представление того же факта.** Кроме пустой строки в
настройках и значения «токен не задан» в пакете отправителя, в транспорте бота
объявлен ещё один тип с тем же смыслом, не употребляемый нигде. Он снимается
этой же задачей: объяснять четвёртое представление дороже, чем удалить мёртвое.
### Решение 4: живой прогон требует заполнить ещё две секции, и это говорится вслух
Пустого токена мало. Настройки входа проверяются на старте и роняют процесс,
называя незаполненные ключи; конструкторы Yandex так же роняют его на пустых
регионе, ключах Object Storage, ключе SpeechKit и папке. Ни один из них при
старте наружу не ходит, поэтому **выдуманных непустых значений достаточно**
живой прогон получается, а денег не стоит.
Этой задачей разрез «пустое значение — отказ от возможности» на другие секции не
переносится: распознавание без Yandex не работает по существу, и отказ от него —
отдельное решение с отдельной ценой. Здесь только называется, что заполнить,
чтобы сервис поднялся.
Отсюда же граница правки документов: строка «живой прогон недоступен» не
снимается, а **сужается с остатком** — стал доступен подъём и осмотр, а прогон с
по-настоящему пустыми ключами Yandex по-прежнему невозможен.
## Risks / Trade-offs
- **Сервис молча работает без бота, потому что токен забыли стереть или забыли
вписать** → строка журнала при старте называет это прямо, а не оставляет
читателю вывод из тишины. Дальше — дело того, кто выкладывает;
- **Отказ старта на негодном токене останавливает выкладку, которая прежде
проходила** → это и есть цель решения 1; чинится правкой настройки, и отказ
называет, какой ключ виноват, не называя значения;
- **Сборка бота ходит в Telegram, и без сети старт с непустым токеном упадёт**
поведение не новое и не ухудшается. Сегодня сборка отправителя зовётся первой и
роняет старт на любом отказе, так что терпимость соседнего пути на негодном
токене всё равно не срабатывает: до неё не доходит очередь. Изменение делает
два пути согласованными и **снимает** сеть с законного пути — пустой токен не
ходит наружу вовсе. Срока ожидания у обращения к Telegram при этом нет, и
задача его не заводит: таймауты у трёх внешних собеседников — известный
недостаток проекта и предмет отдельной работы;
- **Недоставленный ответ пропадает навсегда** → отложенной доставки нет и не
заводится: расшифровка лежит в хранилище и достаётся через панель и HTTP API;
- **Остановка идёт по пути, которым прежде не ходили** → останов зеркален
сборке: чего не собрали, того не закрывают и не ждут. Проверяется прогоном
сигнала остановки на конфиге с пустым токеном.
## Migration Plan
Схемы хранилища изменение не трогает, миграции нет, откат — обычный откат образа.
Выкладка с заполненным токеном ведёт себя ровно как прежде.
## Open Questions
Нет.
@@ -0,0 +1,54 @@
## Why
Сервис принимает записи двумя входами — ботом Telegram и HTTP API, — но
поднимается только тогда, когда настроены оба: пустой токен бота кончает старт
отказом раньше, чем встаёт HTTP-сервер. Боевым токеном запускаться запрещено, и
из этого следует, что **поднять сервис и посмотреть на него живьём не может
никто**: всякая задача, меняющая поведение, проверяется одними тестами.
Намерение «работать без Telegram» в сервисе уже есть — отдельное значение «токен
не задан» и терпимость к отказу сборки бота при старте, — но один путь его
отменяет, и потому оно ничего не значит.
## What Changes
- Ненастроенный вход Telegram больше не мешает подъёму: сервис встаёт и работает
оставшимся входом — принимает записи по HTTP, расшифровывает их и отдаёт текст
туда же. Об отсутствии бота сервис говорит одной строкой журнала при старте, а
не молчанием.
- Задача, пришедшая из Telegram и дошедшая до ответа тогда, когда бота нет,
доводится до конца, а факт недоставки уезжает в журнал владельца. Сегодня такая
задача уронила бы процесс.
- Запрет запускаться боевым токеном остаётся: рядом с ним появляется способ
поднять сервис без токена вовсе.
Ломки нет: с заданным токеном не меняется ничего.
## Capabilities
### New Capabilities
Новых нет: оба требования ложатся в capability, чей раздел `Purpose` сам
называет их своим предметом и приглашает дописать.
### Modified Capabilities
- `intake`: добавляется требование о подъёме с ненастроенным входом Telegram —
сервис работает оставшимся входом. Приём из Telegram по существу (кто допущен,
как скачивается запись) остаётся ненормированным, и оговорка спеки об этом
сохраняется;
- `pipeline`: добавляется требование об ответе отправителю, чей вход не поднят —
шаг не роняется, задача доводится до конца, недоставка идёт в журнал.
## Impact
- сборка сервиса при старте: отправитель ответов и клиент бота;
- ответ отправителю в конвейере расшифровки;
- секция `[telegram]` конфига и её образец `config.dist.toml`;
- `CLAUDE.md`, раздел «Запреты» — рядом с запретом на боевой токен встаёт способ
подняться без него;
- `docs/review.md`, подраздел «Недоступно проверке» — строка о недоступности
живого прогона сужается;
- `docs/architecture.md` — перечень capability и то, что каждая нормирует.
Внешних зависимостей, схемы хранилища и контракта HTTP API изменение не трогает.
@@ -0,0 +1,172 @@
# Ревью изменения `start-without-telegram-token` — отчёт триажа
Прогон 2026-08-13. Отчёт сохранён оркестратором: агент триажа записывать
`.md` не вправе.
## Сводка
- **Режим:** по графу; изменение не закоммичено, база диффа `origin/master`.
- **Метка:** `large` — крупное × знакомое. Повторная разметка после правок
дизайна: первая давала `medium`, исходя из того, что ядро не тронуто; правки
ревью дизайна это допущение сняли.
- **Гейт:** зелёный целиком, 13 шагов, включая `-race`, `golangci-lint`,
`govulncheck`. Оракул снят проходом `autotests`, триаж гейт не перезапускал.
- **Находок на входе:** 19 (specs 3, code 6, architecture 3, adversary 4,
ops 3, autotests 0). **Осталось:** 6 в основном списке, 2 гипотезы,
2 кандидата в промоут; срезы названы поимённо.
### План с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
| --- | --- | --- | --- | --- |
| requirements | `openspec/specs` + дельты change | разбор | specs | закрыта, 3 находки |
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | закрыта, 0 находок |
| conventions | `docs/conventions/` | разбор | code | закрыта, 6 находок |
| architecture | `docs/architecture.md` + `passport.md` | доказательство | architecture | закрыта, 3 находки |
| security | `docs/security.md` | доказательство | adversary | закрыта, 4 находки |
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | ops | закрыта, 3 находки |
Тем без дома нет, тем без отчёта нет. Своих тем у проекта нет, `basics` не
запускался — все темы ядра закрыты именными проходами. Побочное следствие:
независимого второго голоса о заниженности метки на прогоне не было.
## Блокирует мердж
### 1. Токен бота уезжает в журнал целиком при опечатке — critical
`internal/adapter/telegram/bot.go` возвращал отказ конструктора без чистки.
Отказ рождается в `http.NewRequest` на разборе адреса — **до** обращения к
клиенту, то есть мимо `safeClient` и `WithoutURL`. Токен с управляющим символом
или неверной `%`-последовательностью печатался в журнал целиком.
Оракул: воспроизведено тремя проходами независимо и триажем отдельно. Нарушены
инвариант `CLAUDE.md` «Секрет не покидает конфиг» (critical) и MUST дельта-спеки
`intake`.
**Исход: починено инлайн.** Отказ конструктора пропущен через `WithoutURL`.
Заодно закрыта дыра в собственной проверке: прежний тест судил запрет **годным**
токеном, то есть случаем, который и так работал. Добавлен тест с токеном,
ломающим разбор адреса.
### 2. Молчащий Telegram вешает старт навсегда — major
Клиент собран из `&http.Client{}` без срока ожидания, сборка стоит до подъёма
сервера. При Telegram, отвечающем молчанием, процесс висит бесконечно: порт не
слушается, `/health` не отвечает, воркеры не запущены, в журнале ни строки.
Поведение предсуществует изменению, но изменение **записывает его нормой**.
Довод дизайна «различать нечем» проверяемо неверен: отказ Bot API приезжает
типом `*tgbotapi.Error` с кодом, транспортный — нашим после чистки.
**Исход: развилка владельцу.** Цена дописана в таблицу отказов
`docs/architecture.md`; выбор поведения — за владельцем.
## Стоит исправить сейчас
### 3. Сервис без Telegram выглядит здоровым — major
`/health` отдаёт статические `200 ok` и о входах не знает; серий `transcriber_*`
на `/metrics` при неподнятом боте ноль; поля «доставлено» в схеме нет. Владелец
узнаёт о потерянном входе только из журнала контейнера и только до ротации.
**Исход: развилка владельцу.** Попутно исправлено фактическое: обоснование нормы
называло третьим последствием перезапись служебных полей завершённой задачи —
такого не бывает, переход в терминальное состояние снимает захват, и повторная
запись натыкается на «захват потерян». Довод сведён к двум последствиям.
### 4. Задача из Telegram, потерявшая чат, считается успешной — major
Было `ERROR` и отказ шага, стало `WARN` и успех. Тем самым снят самый громкий
детектор класса, который `CLAUDE.md` называет самым коварным: колонка, выпавшая
из пары `acquireColumns`/`acquiredRow`, обнуляет чат у задачи, попавшей к
воркеру.
**Исход: развилка владельцу** — развести уровни по причине или оставить.
### 5. Разрез старта не держался ни одним тестом — minor
Инвертируй разрез — весь набор оставался зелёным.
**Исход: починено инлайн.** Решение вынесено из `main` в `telegramFromBot` и
накрыто тремя случаями. Обращение к Telegram отделено от решения намеренно:
обращение ходит в сеть и в проверке недоступно, а разрез проверять надо.
### 6. Образец конфига и три документа описывали снятое поведение — minor
`config.dist.toml` оставлял непустой плейсхолдер, хотя собственный комментарий
рядом объявлял пустой токен режимом: копия образца старт роняла.
`docs/conventions/config.md` описывал снятый механизм строкой «Расхождение», а
она в этом проекте выдаёт индульгенцию будущим ревью. `docs/conventions/errors.md`
перечислял удалённый тип. Маркер канона в `docs/architecture.md` ссылался на
несуществующие capability.
**Исход: починено инлайн, все четыре места.**
## Чем кончились развилки — решения владельца 2026-08-13
- **Находка 2 (молчащий Telegram).** Выбран вариант сверх предложенных:
недоступность Telegram на старт не влияет. Разрез перенесён с «пусто /
непусто» на «ответил ли Telegram»: ответ «такого бота нет» роняет старт,
недоступность даёт подъём без Telegram с записью `WARN`. Из решения следует
срок ожидания при сборке — без него недоступность неотличима от подъёма.
- **Находка 3 (наблюдаемость).** Выбран счётчик и признак входов: метрика
поднятости по каждому входу и счётчик недоставленных ответов с причиной
меткой. Колонку в задаче не заводили — это шаг схемы и необратимое.
- **Находка 4 (уровень).** Уровни разведены: неподнятый вход — `WARN`,
неназванный адресат — `ERROR`.
**Отдельно о самом прогоне.** Живой прогон, снятый проходом `adversary`, оставил
процесс работающим на том же порту, и он держал его ещё час. Часть моих проверок
после переделки мерила этот чужой процесс, а не новую сборку; обнаружено по
отсутствию новой метрики, исправлено остановкой процесса и повторным прогоном.
Кандидат в правило: прогон, поднимающий сервис, обязан снимать его за собой, а
проверяющий — убеждаться, что порт занят его собственной сборкой.
## Гипотезы без доказательства
- **`{"ok":true,"result":null}` считается успешной доставкой.** Механизм доказан
на подставном сервере, вторая половина — что живой Telegram так отвечает — не
доказана и по правилам проекта недоказуема. Предсуществует изменению.
- **Второй `os.Exit(1)` недостижим сегодня.** Приемлемая страховка, не дефект:
конструктор объявляет отказ в сигнатуре, и разобрать его вызывающий обязан.
## Кандидаты в промоут
- **Порядок выкладки: конфиг с пустым токеном нельзя выкатывать раньше бинаря.**
Воспроизведено на `origin/master` в отдельном worktree: откат бинаря при уже
применённом пустом токене останавливает весь сервис. Это правило эксплуатации,
которого в проекте нет; дом — `docs/architecture.md`, «Эксплуатация».
- **Сверка документов на упоминания удалённых идентификаторов.** Три из четырёх
мест находки 6 — прямые ссылки на снесённый код и несуществующие capability.
Ловит это `av-dev:doc-healthcheck`, которого зовут руками.
## Границы покрытия
**Что не проверил ни один проход** (`docs/review.md`, «Недоступно проверке»):
поведение SpeechKit и Object Storage под нагрузкой; реальный профиль нагрузки;
стойкость `ffmpeg` к вредоносному входу; поведение настоящей Authelia; поведение
браузера с куками.
**Перестали проверять сознательно:** разбор вывода настоящего `ffprobe`; работа
сервиса с настоящими внешними собеседниками. Подъём живьём стал доступен как раз
этим изменением, но остаток — приём из Telegram, расшифровка, заливка — не
проверяет никто.
**Чего не принесёт ни один прогон:**
1. Решения проекта не сверялись — `docs/adr/` процессный, прогон его не
открывает. Расхождение с записанным решением ловит `av-dev:doc-healthcheck`.
2. Записанные наблюдения не использовались — `docs/research/` тоже процессный.
Всякое число этого отчёта снято на этом прогоне.
3. Поимённой сверки с руководствами по стилю Go не задавал ни один проход.
4. Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.
**Сработавшие потолки.** Потолок триажа: 19 находок → 6. Срезано поимённо:
дубли в тестах (близко к вкусовщине; починено попутно, тот же файл правился
находкой 1); избыточность представлений факта «Telegram не поднят» — шесть
вместо четырёх, одно сократимо, последствие не названо; откат бинаря — уехал в
промоут; вырожденный ответ библиотеки — в гипотезы.
**Отдельная находка о самом прогоне:** проходы отдавали сводки пересказом, и
свои блоки «Coverage of this pass» с потолками до триажа дошли не все — узнать,
срезал ли `code` или `adversary` что-то у себя, из отчёта нельзя.
@@ -0,0 +1,90 @@
## Purpose
Приём записи и опрос готовности задачи расшифровки: что считается принятой
записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём по существу описан пока **только для HTTP** — того, что нормируют
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
требованиями по-прежнему не описано — требование, написанное без проверки, это
предположение, а не норма. Первая задача, которая трогает поведение приёма из
Telegram, дописывает его сюда.
## ADDED Requirements
### Requirement: Недоступный или незаданный вход Telegram не мешает подъёму
Сервис SHALL подниматься, когда вход Telegram поднять не удалось, и MUST
продолжать работу оставшимся входом: приём по HTTP, опрос готовности и конвейер
расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в журнале
**ровно одной** записью уровня `WARN` при старте — с причиной и без значения
токена.
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
бота нет» — ошибка настройки: бот по этому токену не появится ни от ожидания, ни
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
бы неоткуда.
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и класть его
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
здоровья.
Требование нормирует **наличие входа**, а не приём из него.
#### Scenario: Токен не задан
- **GIVEN** в настройках сервиса токен бота пуст
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** конвейер расшифровки работает
- **AND** бот не заведён, а в журнале ровно одна запись уровня `WARN` о том, что
он не поднят и почему
#### Scenario: Токен задан и годен
- **GIVEN** в настройках сервиса стоит токен, по которому Telegram признаёт бота
- **WHEN** сервис запускается
- **THEN** он поднимается и работает обоими входами
#### Scenario: Telegram не отвечает
- **GIVEN** в настройках сервиса стоит непустой токен
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
- **AND** запись не несёт значения токена
#### Scenario: Telegram ответил, что такого бота нет
- **GIVEN** в настройках сервиса стоит непустой токен
- **AND** Telegram отвечает отказом на этот токен
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** ни журнал, ни текст отказа не несут значения токена
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
вход и неподнятый.
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
единственный канал наблюдения, который у владельца автоматизирован.
#### Scenario: Вход Telegram не поднят
- **GIVEN** сервис поднялся без Telegram
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости входа Telegram равен нулю
- **AND** признак поднятости входа HTTP равен единице
@@ -0,0 +1,80 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
когда доставить его некуда.
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
результата держателем захвата и недоставка ответа при неподнятом входе.
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
## ADDED Requirements
### Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
меткой.
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят**
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
журнала, а не в состоянии задачи.
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
запись MUST не попадать — приватность содержимого записи требование не
ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
#### Scenario: Вход отправителя не поднят
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
- **AND** сервис поднялся без этого входа
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
`failed` не переводится
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
задачи и причиной
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
#### Scenario: Адресат у задачи не назван
- **GIVEN** у задачи источником значится Telegram, а чат не назван
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
задачи и причиной: неназванный адресат — симптом порчи записи
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- **GIVEN** задача принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке
@@ -0,0 +1,127 @@
## 1. Отправитель, который не отправляет
- [x] 1.1 Завести среди контрактов значение отказа «канал доставки не поднят» —
рядом с «работы нет» и «захват потерян», узнаваемое тем же способом
- [x] 1.2 Завести в пакете отправителя Telegram заглушку, реализующую контракт
отправки: она ничего не отправляет и на всякий ответ возвращает это
значение
- [x] 1.3 Проверить тестом, что заглушка возвращает именно его и ничего не пишет
сама
## 2. Ответ отправителю в конвейере
- [x] 2.1 Научить ответ отправителю узнавать это значение: пишется запись уровня
`WARN` с идентификатором задачи и причиной, шаг завершается без отказа
- [x] 2.2 Свести к тому же исходу вторую причину недоставки — задачу источника
Telegram без названного чата: сегодня она даёт отказ шага на уже
завершённой работе, то есть ложный сбой в счётчике воркера и перезапись
служебных полей
- [x] 2.3 Проверить, что в записи нет ни текста расшифровки, ни сообщения
отправителя
- [x] 2.4 Тест конвейера: задача источника Telegram доходит до ответа через
заглушку — шаг без отказа, состояние задачи не откатывается, в повтор она
не уходит и в `failed` не переводится
- [x] 2.5 Тест: задача источника Telegram без чата даёт тот же исход
- [x] 2.6 Тест: задача, принятая по HTTP, до заглушки не доходит и записи о
недоставке не порождает
## 3. Сборка при старте
- [x] 3.1 Свести сборку клиента бота к одной: отправитель ответов принимает
готового клиента вместо токена, транспорт получает того же
- [x] 3.2 Поставить разрез в этом единственном месте: пустой токен даёт заглушку
и одну запись уровня `WARN` о неподнятом боте, любой другой отказ сборки
роняет старт
- [x] 3.3 Тест на непустой токен, с которым бот не заводится: старт роняется.
Живой Telegram не нужен — адрес подставляется, как в имеющемся тесте
клиента
- [x] 3.4 Проверить, что ни запись о неподнятом боте, ни текст отказа старта не
несут значения токена
- [x] 3.5 Проверить остановку: сигнал остановки на конфиге с пустым токеном
завершает процесс тем же кодом и в тот же срок, что и с токеном
- [x] 3.6 Удалить неупотребляемый тип отказа «токен пуст» в транспорте бота —
третье представление того же факта
## 4. Настройки и их образец
- [x] 4.1 Описать в образце конфига, что пустой токен означает подъём без
Telegram и что при этом перестаёт работать
- [x] 4.2 Назвать там же остальные секции, без которых сервис не поднимется:
настройки входа и Yandex требуют непустых значений, при локальном прогоне
годятся выдуманные, наружу при старте не ходит ни одна
## 5. Проверки
- [x] 5.1 Тест на сборку отправителя с непустым токеном: прежний путь сохранён
- [x] 5.2 Живой прогон: конфиг с пустым токеном и заполненными по 4.2 секциями,
`GET /health` отвечает `200`, в выводе есть запись о неподнятом боте
- [x] 5.3 `task gate` зелёный
## 7. Развилки ревью кода — решения владельца 2026-08-13
- [x] 7.1 Недоступность Telegram на старт не влияет: разрез перенесён на «ответил
ли Telegram». Ответ «такого бота нет» роняет старт, всё прочее даёт подъём
без Telegram с записью `WARN`
- [x] 7.2 Ограничить ожидание при сборке клиента сроком — без него недоступность
неотличима от подъёма; длинный опрос сроком не ограничен
- [x] 7.3 Признак поднятости входов метрикой и счётчик недоставленных ответов с
причиной меткой
- [x] 7.4 Развести уровни недоставки: неподнятый вход — `WARN`, неназванный
адресат — `ERROR` (симптом порчи записи)
- [x] 7.5 Проверки на все четыре ветки сборки и на оба уровня недоставки
## 6. Документы
- [x] 6.1 `CLAUDE.md`, раздел «Запреты»: рядом с запретом на боевой токен встаёт
способ подняться без него
- [x] 6.2 `docs/review.md`, подраздел «Недоступно проверке»: строка о живом
прогоне сужается **с остатком** — подъём и осмотр стали доступны, прогон с
пустыми ключами Yandex по-прежнему нет
- [x] 6.3 `docs/architecture.md`: перечень capability отражает, что нормируют
`intake` и `pipeline` после этого изменения
- [x] 6.4 `docs/architecture.md`, таблица отказов внешних зависимостей: строка
про Telegram сегодня обещает дежурному «бот не стартует, приложение
продолжает работу без него» — привести к новому разрезу ссылкой на
требование, не перенося поведение в обзор
## Критерии приёмки
Первые три — дословно из записи задачи `local-run-without-telegram-token`.
**Четвёртый переписан** решением владельца на чекпоинте 2026-08-13: в прежней
редакции он требовал, чтобы задача осталась пригодной к повтору либо перешла в
`failed`, а дизайн отверг оба исхода с ценой, и норма `pipeline` требует прямо
обратного. Прежняя редакция сделала бы приёмку зелёной на поведении, которое это
же изменение запрещает. Запись задачи поправлена тем же решением.
- Сервис поднимается с пустым токеном бота: HTTP отвечает, воркеры идут, бот не
создан. Оракул — запуск с конфигом без токена и запрос `GET /health`: код 200.
- Отсутствие бота названо в журнале один раз при старте, а не молчанием. Оракул —
тот же запуск: в выводе есть строка о том, что бот не поднят и почему.
- Поведение с настоящим токеном не изменилось. Оракул — тест на создание
отправителя с непустым токеном: прежний путь сохранён.
- Задача из Telegram, дошедшая до ответа при отсутствующем боте, не роняет
процесс и не теряется молча: она остаётся в достигнутом состоянии, в повтор не
уходит и в `failed` не переводится, а недоставка видна записью журнала с
идентификатором задачи. Оракул — тест конвейера с задачей источника Telegram и
заглушкой вместо отправителя.
- Задача источника Telegram без названного чата даёт тот же исход, а не отказ
шага. Оракул — тест конвейера на такой задаче: воркеру сбой не засчитан,
служебные поля завершённой задачи не переписаны.
Сверх записи задачи — из ревью дизайна:
- Непустой токен, с которым бот не заводится, роняет старт. Оракул — тест с
подставным адресом Bot API.
- Записей о неподнятом боте ровно одна. Оракул — живой прогон с пустым токеном:
отбор по журналу даёт одну строку, а не две.
- Клиент бота собирается в одном месте. Оракул — отправитель ответов принимает
клиента, а не токен, и `NewBot` зовётся из сборки при старте однажды.
Сверх ревью кода — решения владельца по трём развилкам:
- Недоступность Telegram подъёму не мешает, ответ «такого бота нет» роняет старт.
Оракул — проверки на четыре ветки сборки.
- Поднятость входов видна метрикой. Оракул — живой прогон с пустым токеном:
признак входа Telegram равен нулю, признак HTTP — единице.
- Неназванный адресат пишется уровнем `ERROR`, неподнятый вход — `WARN`. Оракул
— проверки конвейера на обе причины.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-13
@@ -0,0 +1,240 @@
## Context
Разрез «поднимать ли вход Telegram» сегодня проходит по пустоте ключа доступа:
`telegram.bot_token = ""` означает и «вход выключен намеренно», и «ключа нет».
Разрез объявлен решением владельца от 2026-08-13 и записан в
[ADR-2026-08-13-telegram-outage-does-not-block-startup](../../../docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md);
здесь меняется не он, а то, **откуда** сервис узнаёт намерение владельца.
Ограничения, с которыми считаемся:
- файл настроек на сервере собирает Ansible из `pet-project-server`, и ключ
доступа приезжает туда из внешнего хранилища секретов. Значение, потерянное при
сборке, неотличимо от решения владельца;
- инвариант «секрет не покидает конфиг» — ни сообщение об отказе старта, ни
запись журнала не несут значения ключа. Проверка секции `[auth]` уже устроена
так и служит здесь образцом;
- локальный прогон боевым токеном запрещён, и подъём без Telegram — его обычный
режим. Он не должен стать труднее.
## Goals / Non-Goals
**Goals:**
- признак включения объявляет намерение, ключ доступа означает только доступ;
- включённый вход без ключа роняет старт с внятным сообщением;
- выключенный вход сообщается записью журнала, не поднимая уровень до
предупреждения;
- локальный прогон одним входом остаётся одной строкой настройки.
**Non-Goals:**
- приём записи из Telegram, белый список и доставка ответов;
- чистка прочих путей, где секрет мог бы уехать наружу: работа закрывает один
названный ревью — текст отказа разбора файла настроек;
- единое место проверки настроек для всех секций: `[auth]` и `[telegram]` пока
проверяются каждая своим методом, и сведение их в один проход — отдельная
работа;
- правка шаблона настроек в `pet-project-server`: его правит человек, здесь он
только назван.
## Decisions
### Признак обязателен, умолчания у него нет
Файл настроек без ключа `enabled` негоден: загрузка кончается отказом, и процесс
выходит с ошибкой настройки. Решение владельца от 2026-08-13.
Довод: умолчание — это угаданное намерение, а признак заводится ровно затем,
чтобы намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе
стороны, и любое умолчание делает одну из двух ошибок тихой.
Альтернативы и причина отказа:
- **умолчание «включён»** — отвергнуто владельцем: файл без признака работал бы
«как-нибудь», и разница между объявленным и угаданным намерением исчезала бы
ровно там, где её завели;
- **умолчание «выключен»** — отвергнуто и по тому же доводу, и отдельно: первый
же подъём после выкладки выключил бы бота молча. Это исход, против которого
написано само требование.
Цена решения — порядок выкладки: шаблон настроек обязан получить признак раньше
образа. Она названа в разделе «Migration Plan» и на чекпоинте.
### Отсутствие ключа ловит загрузчик, пустой ключ — проверка секции
Разрез идёт по тому, **о чём судим**. Отсутствие ключа — свойство файла, и
видит его только разбор: `toml.DecodeFile` отдаёт `MetaData`, и `IsDefined`
отвечает, был ли ключ в файле вообще. Значение поля — свойство настройки, и
судит его `TelegramConfig.Validate()` по образцу `AuthConfig.Validate()`.
Альтернатива — сделать поле `*bool` и свести обе проверки в `Validate()`
отвергнута: указатель переживает проверку и уезжает к потребителям, где `nil`
уже невозможен, но выглядит возможным. Читатель настройки платит за форму,
нужную одному разбору.
`MetaData` из `LoadConfig` наружу не отдаётся: отказ формируется на месте, и
знание о разборе не растекается.
### Проверка ключа живёт в настройках, а не в сборке входа
`TelegramConfig.Validate()` зовётся из `main.go` сразу после загрузки, рядом с
проверкой секции `[auth]`, роняет процесс, называет **имя** незаполненного ключа
и не касается значения.
Альтернатива — оставить проверку внутри сборки клиента, как сейчас, — отвергнута:
сборка ходит в сеть, и отказ настройки смешался бы там с отказом Telegram. Читать
разрез пришлось бы по типу ошибки, а не по месту.
### Ветка «токен пуст» в разборе сборки меняет исход
Сегодня `telegramFromBot` на `telegram.ErrEmptyToken` отдаёт мягкий исход: сервис
поднимается без Telegram. После разведения это состояние по построению
недостижимо — проверка настроек ловит его раньше, — но ветку не убираем: она
получает исход «ошибка настройки, старт роняется» и встаёт рядом с отказом Bot
API.
Причина: удалённая ветка оставила бы пустой ключ падать в общий случай `err !=
nil`, то есть в «недоступность», и обход проверки настроек дал бы тихий подъём —
ровно то, что мы убираем. Ветка, недостижимая по построению, но дающая верный
исход, дешевле ветки, дающей неверный.
Единая точка `telegram.NewBot` и значение `telegram.ErrEmptyToken` остаются как
есть: они держат инвариант «Bot API только через нашего клиента».
### Отказ разбора файла настроек говорит своими словами
Найдено ревью дизайна и чинится этой же работой по решению владельца.
`toml.DecodeFile` отдаёт отказы двух семейств, и значения несёт **только одно**:
- `toml.ParseError` — сюда сведены отказы лексера и разбора значения, и его поле
`Message` собирается из разбираемого куска (`Invalid float value: %q`,
`invalid duration: %q`, `%v is out of range`). Незакавыченный токен из криво
собранного шаблона выкладки попадает в текст целиком. Из этого отказа берём
**строку, столбец и последний ключ** — они безопасны, — а `Message` не берём;
- прочие отказы декодера (несовпадение типов, неподдерживаемый тип) собираются
из **имён ключей и имён типов**, значений в них нет. Их текст берём как есть:
выбрасывать его значило бы платить разборчивостью отказа там, где платить не за
что.
Отвергнутые альтернативы:
- **выбросить текст обоих семейств** — просто и закрыто наглухо, но за
несовпадение типов (`port = "8080"`) владелец получал бы «файл не
разбирается» без единого намёка, а значения там нет по построению;
- **вычищать значения из текста** — вычищать не с чем: разбор не состоялся, и
значений в настройках ещё нет;
- **брать `Message`, когда последний ключ не секретный** — перечень секретных
ключей живёт в конвенции и разошёлся бы с кодом молча, а расхождение здесь
означает утечку.
**Спеки это не меняет, и требования под себя не заводит.** Норма уже записана и
сильнее спеки: инвариант «Секрет не покидает конфиг» в `CLAUDE.md` со степенью
`critical`. Работа приводит код в соответствие с записанным, а не заказывает
новое поведение. Форма записи отказа уезжает в конвенцию настроек, раздел
«Секреты», — там её дом.
**Дом нормы назначен явно, и это выбор, а не умолчание.** Загрузка настроек не
принадлежит ни одной заведённой capability: `intake` сама объявляет, что нормирует
наличие входа, а не приём; `access`, `pipeline` и `storage` к разбору файла
отношения не имеют. Заводить capability подъёма ради одного семейства отказов
дороже выигрыша, а вписывать разбор настроек в `intake` значит переносить туда
чужое. Поэтому дом нормы — **инвариант `CLAUDE.md` плюс конвенция
`docs/conventions/config.md`**, и спеки загрузку настроек не нормируют.
Найдено ревью кода; цена решения в том, что при следующей ревизии семейства
отказов спека не скажет ничего и опорой будут конвенция и проверки.
### Выключенный вход — уровень `INFO`
Предупреждение говорит «случилось не то, что ты просил». Выключенный вход — ровно
то, что просил владелец, и на каждом локальном прогоне это давало бы шум,
неотличимый от настоящей недоступности. Недоступность остаётся `WARN`.
Признак поднятости входа (`IntakeUpGauge`) выставляется во всех случаях, включая
выключенный: наблюдателю нужен ответ «работает ли вход сейчас», а не «почему».
### Сборка входа получает настройки секцией, а решение о выключенном входе — шов
`buildTelegram` принимает `config.TelegramConfig` целиком вместо одного токена:
решение «поднимать или нет» читает оба поля, и разносить их по двум аргументам
значит заводить два места, где их сверяют.
Само решение уезжает в `telegramFromConfig(cfg, newBot, logger)`, где `newBot`
параметр-функция сборки клиента; `buildTelegram` подставляет туда
`telegram.NewBot`. Иначе главное утверждение выключенного входа — **обращения к
Telegram не уходит ни одного** — проверить нечем: `telegram.NewBot` держит адрес
Bot API внутри, и проверка, судящая по исходу, останется зелёной и тогда, когда
ветка выключенного входа встанет **после** обращения. Тогда прогон с заполненным
ключом ходил бы в живой Telegram боевым токеном, а проверка этого не заметила бы.
Шов — параметр-функция, а не интерфейс: реализация у него одна, и вводить ради
неё тип значит заводить понятие там, где хватает подписи. Прецедент в проекте
свой и того же рода — `telegram.newBot(token, endpoint, logger)` принимает адрес
отдельно ровно затем, чтобы проверка не ходила в сеть.
## Risks / Trade-offs
- **Файл настроек на сервере отстал от кода** → сервис не поднимется вовсе:
признака в файле нет, загрузка кончается отказом. Это главный риск работы, и
снимается он порядком выкладки — сперва шаблон настроек, потом образ. Отказ
громкий, называет ключ и виден в первую же минуту; молчаливая потеря бота
обошлась бы дороже, но порядок соблюсти обязан человек.
- **Локальный файл настроек отстал от кода** → тот же отказ и та же починка:
одна строка `enabled = false`.
- **Проверок настроек стало две вместо одной** → расхождение между ними ловится
только глазами. Сведение в один проход названо Non-Goal и остаётся работой на
потом.
- **Ошибочный `enabled = false` из шаблона выкладки** → работа закрывает одно
русло молчаливой потери бота (потерян ключ доступа) и оставляет второе:
признак, отрендеренный ложным из-за пропущенной переменной, отличим от решения
владельца **только записью журнала**`INFO` против `WARN`. Признак
поднятости входа тут не помощник: он равен нулю и при выключенном входе, и при
недоступности Telegram, то есть от аварии этот случай не отделяет, а
собственного оповещения у проекта нет вовсе. Сервис поднимается штатно, и
владелец узнаёт о беде от молчащего бота — тем же способом, что и прежде.
Ненаписанный риск читается как несуществующий, поэтому он назван здесь: ключ
`enabled` в шаблоне выкладки критический.
- **Остаточный риск утечки при смене версии библиотеки разбора** → разрез ниже
опирается на то, какие семейства отказов несут значения сегодня. Версия
библиотеки, переложившая значение в другое семейство, вернёт утечку молча.
Держится это проверкой на поломанной строке секретного ключа; она же краснеет
при таком переносе.
- **Ветка, недостижимая по построению**, живёт в коде и её нельзя проверить
через настройки → проверяется напрямую на уровне разбора исхода сборки, как
уже устроены соседние ветки.
## Migration Plan
Порядок обязателен, и нарушение его роняет сервис на сервере.
1. Код и образец настроек едут вместе: `config.dist.toml` получает
`enabled = false` при пустом ключе доступа — это состояние свежей локальной
установки.
2. **Раньше накатки образа** шаблон настроек в `pet-project-server` получает
строку `enabled = true`, и файл на сервере перерисовывается. Правит человек,
отдельно от этой работы; пока правки нет, новый образ на сервер не едет.
3. Только после этого едет образ.
Откат: вернуть прежний образ. Файл настроек с ключом `enabled` прежний код
разбирает без отказа — лишний ключ TOML разбор не роняет, он просто не читается,
и бот поднимается по непустому токену.
**Откат при выключенном входе допустим только на образ от 2026-08-13 и новее.**
На более старом состояния «сервис поднят, бот опущен» не существует вовсе:
пустой ключ роняет старт, негодный роняет старт, годный поднимает бота. Откат
туда делают с непустым годным ключом, приняв, что бот поднимется; рецепт ниже на
таком образе ведёт к выходу с кодом 1 до открытия порта.
**Один случай отката требует и отката настроек** — `enabled = false` при
заполненном ключе доступа, то самое состояние, ради которого два значения и
разводятся. Прежний код признака не видит и поднимает бота, то есть отменяет
решение владельца молча. Если вход был выключен потому, что бот с этим токеном
поднят где-то ещё, два процесса поделят один длинный опрос и часть ответов до
людей не дойдёт — прямо тот вред, который называет запрет «Боевым токеном бота не
запускаться». Откат в этом состоянии начинается с очистки ключа доступа.
## Open Questions
Открытых нет: умолчание признака решено владельцем 2026-08-13 — признак
обязателен, умолчания у него нет.
@@ -0,0 +1,67 @@
## Why
Сегодня пустой токен бота означает сразу две разные вещи: «вход Telegram
выключен намеренно» и «ключа доступа нет». Владелец не может сказать сервису
«бот мне нужен» отдельно от «вот ключ», а сервис не может отличить осознанный
отказ от входа от криво отрендеренного файла настроек — и в обоих случаях
поднимается без бота.
Цена расхождения падает на выкладку: файл настроек собирает Ansible, и потерянный
при сборке ключ выглядит для сервиса ровно так же, как решение владельца обойтись
одним входом. Бот молча перестаёт отвечать своим отправителям, а узнать об этом
неоткуда.
## What Changes
- В настройках входа Telegram появляется отдельный признак включения. Он и
объявляет намерение: нужен ли сервису этот вход вообще.
- Ключ доступа перестаёт нести второе значение. Он читается и проверяется
**только** при включённом входе, а при выключенном не смотрится вовсе.
- Включённый вход без ключа доступа становится ошибкой настройки: сервис
говорит, какого ключа не хватает, и не поднимается. Прежде такой файл давал
тихий подъём без бота.
- Выключенный вход перестаёт быть поводом для предупреждения в журнале: решение
владельца сообщается обычной записью, а предупреждение остаётся за тем, чего
владелец не выбирал, — недоступностью Telegram.
- Отказ разбора файла настроек перестаёт пересказывать библиотеку разбора и
говорит своими словами: где сломалось и на каком ключе, но не что там
написано. Прежде поломанная строка секретного ключа уезжала в журнал вместе со
своим значением.
- **BREAKING** для файла настроек: у секции Telegram появляется новый
**обязательный** ключ. Умолчания у него нет: файл без признака негоден, и
сервис выходит с ошибкой настройки. Решение владельца от 2026-08-13 — намерение
объявляют, а не угадывают по умолчанию, и файл, где его забыли объявить, не
должен работать «как-нибудь».
Прежние правила подъёма при включённом входе сохраняются целиком: Telegram
отвечает «такого бота нет» — старт кончается отказом; Telegram недоступен или
молчит дольше срока — сервис поднимается одним входом и говорит об этом
предупреждением. Признак поднятости входа наблюдателю виден во всех случаях.
## Capabilities
### New Capabilities
Новых нет: речь о том, с какими входами сервис вправе подняться, а это уже
нормировано.
### Modified Capabilities
- `intake`: требование «Недоступный или незаданный вход Telegram не мешает
подъёму» перестаёт выводить намерение из ключа доступа. Оно начинает опираться
на объявленный признак включения, получает два новых отказа старта — признака
в настройках нет и вход включён без ключа — и разводит уровни записей журнала
по тому, выбрал ли владелец это состояние.
## Impact
- Настройки: секция `[telegram]` в `config.toml` и в образце
`config.dist.toml`; структура настроек и умолчания в `internal/config`.
- Подъём: разбор случая при сборке входа Telegram (`telegram_build.go`) и вызов
проверки настроек в `main.go`.
- Выкладка: шаблон настроек в `pet-project-server` обязан получить признак
включения **до** накатки нового образа, иначе сервис не поднимется. Правит его
человек, здесь только называем.
- Документы: запрет на боевой токен в `CLAUDE.md`, конвенция настроек
`docs/conventions/config.md`, таблица отказов в `docs/architecture.md`.
- Приём записи из Telegram, белый список и доставка ответов не затрагиваются.
@@ -0,0 +1,86 @@
# Ревью кода — telegram-enabled-flag
Метка `medium`, режим по графу. Состав: `autotests`, `specs`, `code`, `basics`,
`triage`. Проходы `adversary`, `ops`, `architecture` не запускались — живут с
метки `large`.
## План с исходом по каждой теме
| Тема | Дом | Глубина | Кто закрывает | Исход |
| --- | --- | --- | --- | --- |
| requirements | `openspec/specs/intake/spec.md` + дельта | разбор | specs | закрыта, 3 находки |
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | закрыта, 3 находки |
| conventions | `docs/conventions/config.md` | разбор | code | закрыта, 3 находки |
| architecture | `docs/architecture.md`, «Компоненты», «Единые точки» | разбор | basics | закрыта, находок нет |
| security | `docs/security.md` | разбор | basics | закрыта, находок нет |
| operations | `docs/architecture.md`, «Эксплуатация» | разбор | basics | закрыта, 2 находки |
Тем без отчёта нет, тем без дома нет, своих тем проекта нет.
## Состояние гейта
Зелёные: `build`, `vet`, `gofmt`, `tests` (`-race`, флака нет при `-count=1`
трижды), `golangci-lint` (0 issues), `shell`, `dockerfile`, `go-version`,
`migrations`, `openspec`, `vulns`.
Красные: `docs` и `tasks` — «проект приведён к раскладке версии 3, текущая — 4».
Краснота **унаследована**: проход `autotests` воспроизвёл её в отдельном рабочем
дереве на чистом `903941f` без диффа. Чинится операцией `upgrade` скилла
`av-dev:canon` и к этой работе не относится.
`vulns`: единственная уязвимость `GO-2026-5932` в `golang.org/x/crypto/openpgp`
недостижима из кода и уже названа в `CLAUDE.md`.
## Находки и что с ними сделано
15 сырых находок, после дедупликации по причине — 8 живых.
| № | Находка | Severity | Исход |
| --- | --- | --- | --- |
| — | `telegramFromConfig` не прогонялся с включённым входом (покрытие `2 0`) | major | починено до остальных проходов: два теста на связку «собрать клиента → разобрать исход» |
| — | Ветка `decodeError` с пустым последним ключом не покрыта (`1 0`) | major | починено: тест на опечатку «незакрытая скобка секции» |
| 1 | Раздел «Эксплуатация» предписывает обратный порядок выкладки — по нему сервис не поднимется вовсе | major | **принята**, `docs/architecture.md` переписан: порядок задаётся по ключу, а не по файлу, плюс два случая отката |
| 2 | Сторож инварианта «секрет не покидает конфиг» зелен по построению | major | **принята**, проверка переписана честно; оракул триажа — мутация `Validate()` на утечку оставляла её зелёной |
| 3 | Шапки `ErrEmptyToken` и `AbsentMessageSender` защищают снятое поведение | minor | **принята**, обе переписаны под новый разрез |
| 4 | Признак поднятости входа не держится ни одной проверкой | minor | **принята**, утверждение о нуле добавлено в тест выключенного входа |
| 5 | Новый абзац конвенции снимает с учёта непроверяемую границу `update_timeout` | minor | **принята**, утверждение сужено до двух ключей |
| 6 | Норма «отказ разбора не несёт значения» живёт вне спек, дом не назначен | minor | **принята как развилка (а)**: дом назначен явно в `design.md` — инвариант плюс конвенция |
| — | Смена версии библиотеки разбора могла бы вернуть утечку | гипотеза | действия не требует: ловится добавленными проверками, подтверждено мутацией |
| — | Третий случай отката: образ старше 2026-08-13 | гипотеза | свёрнуто в находку 1, записано вопросом владельцу |
Проход `security` находок не дал: починку утечки он проверил по исходникам
библиотеки независимо и признал разрез верным.
## Сигнал о заниженной метке
`review-code` подал сигнал: изменение вводит обязательный ключ настроек без
умолчания, уже выложенный файл после этого не грузится, а имя ключа конфига
проект числит необратимым. На метке `large` порядок выкладки и откат закрывал бы
отдельный проход `ops`. Сигнал материализовался находкой 1 — её нашёл `basics`
попутно, а не проход, для неё предназначенный. `review-basics` возражений по
метке не подавал. Метка прогона не пересматривалась: правило запрещает.
## Границы покрытия
- **Три прохода не запускались**`adversary`, `ops`, `architecture`. Уносят с
собой враждебный разбор входов, отдельный разбор выкладки и отката, и
независимый разбор архитектурного решения.
- **Ни один из четырёх проходов не сообщил свой потолок и остаток за срезом.**
Это находка о самом прогоне: без такой строки «находок больше нет»
неотличимо от «больше не поместилось». У `basics` риск выше прочих — одна
квота на три темы.
- **Решения проекта не сверялись**: `docs/adr/` — процессный документ, прогон его
не открывает. Расхождение с `ADR-2026-08-13-telegram-outage-does-not-block-startup`,
который это изменение частично отменяет, ловит не ревью, а сверка документации.
- **Записанные наблюдения не использовались**: `docs/research/` не открывался.
- **Поимённая сверка с руководствами по стилю Go не задавалась никем** — в
частности, для шва-параметра вместо интерфейса.
- **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет** — проход независимой реализации снят по стоимости.
- **Живьём проверяемо не всё.** Подъём, отказ старта, маршруты и остановка —
проверены. Приём из Telegram, расшифровка и заливка — нет: боевым токеном
запускаться запрещено, ключи Yandex выдуманы, распознавание подменяется в
коде. Новый разрез при включённом входе с настоящим ботом не проверялся ничем,
кроме подставной сборки клиента.
- Шаблон настроек в `pet-project-server` лежит в чужом репозитории и во вход не
входил ни одному проходу.
@@ -0,0 +1,63 @@
# Ревью дизайна — telegram-enabled-flag
Метка `medium`, назначена агентом `review-scope` (размер среднее, сложность
знакомое). Режим по графу. Состав по метке: `specs` (режим «дизайн ДО кода») и
`rubric`. Триажа на этой стадии нет — сток стадии — шаг отработки замечаний.
## Находки и что с ними сделано
| Проход | Находка | Severity | Исход |
| --- | --- | --- | --- |
| specs | У выключенного входа нет оракула: «бот не заведён, отказа нет» остаётся верным и когда ветка встала **после** обращения, а обращение ушло боевым токеном в живой Telegram | major | принята. Заведён шов `telegramFromConfig(cfg, newBot, logger)`, шаг 3.3 судит по счётчику вызовов, а не по исходу |
| specs | `ADR-2026-08-13-telegram-outage-does-not-block-startup` утверждает, что старт роняет ровно один исход сборки клиента; после изменения их два | minor | принята. Шаг 4.7: новый ADR и парный статус прежнему |
| specs | `docs/architecture.md` (перечень capability) и `docs/review.md` (рецепт живого прогона) останутся ложными: они учат поднимать сервис пустым токеном | minor | принята. Шаги 4.5 и 4.6 |
| specs | Ошибочный `enabled = false` из шаблона выкладки — оставшееся русло молчаливой потери бота — в рисках не назван | minor | принята. Строка в `Risks / Trade-offs` |
| rubric | Отказ разбора файла настроек может унести секрет в журнал: `toml.ParseError` встраивает разбираемое значение в текст | major | **снята из объёма, ушла в урожай.** Путь существует сегодня (`LoadConfig` заворачивает через `%w`, `main.go:49` печатает целиком) и этой работой не заводится; у починки своя цена — потеря подробности отказа |
| rubric | Задача, принятая из Telegram до выключения входа, завершится, а ответ не уйдёт | major | **снята: ложноположительная.** Уже нормировано `openspec/specs/pipeline/spec.md`, «Недоставленный ответ не роняет шаг», сценарий «Вход отправителя не поднят»: `WARN`, метрика, идентификатор задачи. Проход читал только `intake`; `specs` пришёл к тому же выводу независимо |
| rubric | Откат образа при `enabled = false` и непустом ключе тихо поднимает выключенного бота | minor | принята. Абзац в `Migration Plan` |
| rubric | `docs/conventions/config.md`, раздел «Структура в коде», останется утверждать, что умолчание есть у каждого поля | minor | принята. Шаг 4.4 расширен на второй раздел |
## Правки, сделанные по урожаю
Дельта-спеки не менялись ни одной правкой — значит разметка не повторялась и
метка осталась `medium`. Правки легли в `design.md` (шов сборки, два риска,
абзац отката) и в `tasks.md` (шаги 2.2, 3.2, 3.3, 4.4, 4.5, 4.6, 4.7, рубрика в
критерии приёмки).
## Сознательно не сделано
Сценарий «Вход выключен» не получил строки `**AND** признак поднятости входа
Telegram равен нулю`. Её держит соседнее требование «Поднятые входы видны
наблюдателю», чей сценарий стоит на премиссе «сервис поднялся без Telegram» и
новое состояние покрывает. Правка изменила бы дельта-спеку и потребовала бы
повторной разметки, не дав сегодня ничего.
## Границы спеки — что осталось неопределённым
- **Небулево значение признака** (`enabled = "yes"`) попадает в общий отказ
разбора и имени ключа не называет, хотя оба соседних сценария отказа этого
требуют.
- **Несколько негодных секций разом**: `[auth]` и `[telegram]` проверяются
порознь, и спека не говорит, обязан ли отказ перечислить все ключи.
- **`update_timeout` при выключенном входе** — читается или игнорируется, не
нормировано. Вреда нет, но вопрос стал видимым: сборка получает секцию целиком.
- **Проба готовности при выключенном входе** ни одним требованием не связана с
признаком. Граница существовала и до изменения.
## Границы покрытия стадии
- Кода нет по построению: направление `spec → code` недоступно, судилось только
задуманное.
- Рубрика составлена не открывая код и дизайн — иначе она подстроилась бы под
увиденное.
- Решения (`docs/adr/`) и измеренные числа (`docs/research/`) прогон ревью не
открывает: расхождение изменения с записанным решением ловит не он, а сверка
документации. Здесь оно всё же всплыло — проход `specs` наткнулся на ADR через
ссылку из `design.md`, а не обходом каталога.
- Ничего не запускалось: `openspec validate --strict` — единственная выполненная
команда.
- `review-architecture` на предложении не запускался: он живёт с метки `large`.
Вопрос «не появился ли второй способ делать то же самое» на этом изменении не
задавал никто.
- Шаблон настроек в `pet-project-server` лежит в чужом репозитории и во вход не
входил ни одному проходу.
@@ -0,0 +1,113 @@
## REMOVED Requirements
### Requirement: Недоступный или незаданный вход Telegram не мешает подъёму
**Reason**: Требование выводило намерение владельца из ключа доступа: пустой ключ
означал разом и «вход выключен», и «ключа нет». Разведение этих двух значений
меняет и премиссу требования — включённый вход без ключа теперь подъёму мешает,
и прежнее имя стало неверным.
**Migration**: Заменено требованием «Признак включения решает, поднимается ли
вход Telegram». Прежние правила для включённого входа перенесены в него дословно;
добавлены случай выключенного входа и случай включённого входа без ключа.
## ADDED Requirements
### Requirement: Признак включения решает, поднимается ли вход Telegram
Намерение владельца SHALL объявляться отдельным признаком включения входа
Telegram, а ключ доступа MUST означать только доступ. При выключенном входе
сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе.
При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя
незаполненного ключа и MUST не нести его значения.
Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где
признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав
недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён
затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок
тихой — либо бот молча пропадает, либо файл без признака молча работает.
Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO`
при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем;
предупреждение остаётся за тем, чего владелец не выбирал.
При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и
MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и
конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в
журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без
значения ключа.
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы
неоткуда.
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
здоровья.
Требование нормирует **наличие входа**, а не приём из него.
#### Scenario: Вход выключен
- **GIVEN** в настройках сервиса вход Telegram выключен
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** конвейер расшифровки работает
- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что
вход выключен настройкой
#### Scenario: Вход выключен, а ключ доступа задан
- **GIVEN** в настройках сервиса вход Telegram выключен
- **AND** ключ доступа при этом заполнен
- **WHEN** сервис запускается
- **THEN** он поднимается без Telegram, и бот не заводится
- **AND** к Telegram не уходит ни одного обращения
#### Scenario: Вход включён, а ключа доступа нет
- **GIVEN** в настройках сервиса вход Telegram включён
- **AND** ключ доступа пуст
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** сообщение об отказе называет имя незаполненного ключа
#### Scenario: Признака включения в настройках нет
- **GIVEN** в настройках сервиса нет признака включения входа Telegram
- **AND** ключ доступа заполнен и Telegram признаёт по нему бота
- **WHEN** сервис запускается
- **THEN** старт кончается отказом настройки
- **AND** сообщение об отказе называет недостающий ключ
#### Scenario: Вход включён и ключ годен
- **GIVEN** в настройках сервиса вход Telegram включён
- **AND** стоит ключ, по которому Telegram признаёт бота
- **WHEN** сервис запускается
- **THEN** он поднимается и работает обоими входами
#### Scenario: Telegram не отвечает
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
- **AND** запись не несёт значения ключа
#### Scenario: Telegram ответил, что такого бота нет
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
- **AND** Telegram отвечает отказом на этот ключ
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** ни журнал, ни текст отказа не несут значения ключа
@@ -0,0 +1,160 @@
## Критерии приёмки
Постановка пришла текстом и критериев не назвала. Ниже — **предложенные**;
данными они становятся после ответа на чекпоинте.
- В секции `[telegram]` файла настроек есть ключ `enabled`, и он один решает,
поднимается ли вход. Ключ доступа второго значения не несёт.
- Файл настроек с `enabled = false` даёт подъём одним входом, к Telegram не
уходит ни одного обращения, а в журнале ровно одна запись уровня `INFO`.
- Файл настроек с `enabled = true` и пустым `bot_token` роняет старт; сообщение
называет имя ключа и не содержит его значения.
- Файл настроек без ключа `enabled` негоден: загрузка кончается отказом, и
сообщение называет недостающий ключ. Умолчания у признака нет.
- Прежние правила при включённом входе сохранены: отказ Bot API роняет старт,
недоступность Telegram даёт подъём с записью уровня `WARN`.
- Признак поднятости входа Telegram выставляется во всех случаях, включая
выключенный.
- `task gate` зелёный.
Ниже — рубрика ревью дизайна, теми же критериями. Пункты, целиком совпавшие с
перечнем выше, не повторяются.
- **Таблица режимов полна.** Для каждой комбинации «признак задан или нет ×
признак истинен или ложен × ключ доступа пуст, непуст или подсказка» назван
ровно один исход из трёх: подъём с ботом, подъём без бота, отказ старта.
- **Опечатка не выключает вход молча.** `enable`, `Enabled`, ключ в чужой
секции, отсутствующая секция — каждый случай даёт отказ, а не тихий выбор
режима по нулевому значению.
- **Проверка целиком предшествует необратимому.** Приговор о настройках выносится
до открытия порта, до применения шагов схемы и до создания каталогов.
- **Выбранный режим наблюдаем, и наблюдаемость различает основания.** Из журнала
и метрик видно и «работает ли вход сейчас», и «по какому основанию он не
поднят»: выбор владельца, ошибка настройки, недоступность собеседника.
- **Решение о режиме принимается один раз и в одном месте.** Порядок «умолчания
→ файл → приговор» зафиксирован; ни один потребитель не пересчитывает
«поднят ли вход» из полей настроек самостоятельно.
- **Виды отказа различимы по сообщению:** файла нет, файл не разбирается, ключ
не задан, ключ задан негодно — по каждому видно, что чинить, и код выхода
ненулевой.
- **Выключенный вход не оставляет хвостов.** Клиент не заводится, сетевого
обращения нет, остановка не ждёт несуществующего собеседника.
- **Обратная совместимость файла названа в обе стороны** — что делает новый код
со старым файлом и старый код с новым, вместе с порядком выкладки и условиями
отката.
- **Отсутствие умолчания объявлено там, где записана конвенция**, а не только
комментарием в коде.
- **Отказ разбора файла настроек не несёт содержимого файла.** Поломанная строка
секретного ключа даёт отказ с номером строки и именем ключа, но без единой
подстроки значения. Несовпадение типов при этом по-прежнему называет ключ и
типы.
## 1. Настройки
- [x] 1.1 Добавить поле `Enabled bool` с тегом `toml:"enabled"` в
`config.TelegramConfig`; умолчания в `defaultConfig()` для него не заводить
и объяснить это комментарием
- [x] 1.2 Поднять `MetaData` из `toml.DecodeFile` в `LoadConfig` и отказывать в
загрузке, когда ключ `telegram.enabled` в файле не задан; сообщение
называет ключ. `MetaData` наружу из `LoadConfig` не отдавать
- [x] 1.3 Написать `TelegramConfig.Validate()` по образцу `AuthConfig.Validate()`:
при `Enabled` и пустом `BotToken` вернуть отказ с именем ключа `bot_token`
и без его значения
- [x] 1.4 Позвать `cfg.Telegram.Validate()` в `main.go` рядом с проверкой
секции `[auth]`; отказ роняет процесс через `logger.Error` и `os.Exit(1)`
- [x] 1.5 Проверить `TelegramConfig.Validate()` тестами: включён и ключ есть —
ошибки нет; включён и ключ пуст — ошибка называет `bot_token` и не несёт
значения; выключен и ключ пуст — ошибки нет
- [x] 1.6 Проверить `LoadConfig` тестом на временном файле: секция `[telegram]`
без ключа `enabled` даёт отказ с именем ключа; с ключом — загрузка проходит
и значение доезжает обоими значениями
## 1а. Отказ разбора не несёт содержимого файла
- [x] 1а.1 В `LoadConfig` перестать заворачивать отказ `toml.DecodeFile` через
`%w`: разобрать его по семействам и собрать сообщение самому
- [x] 1а.2 `toml.ParseError` (через `errors.As`) — взять путь, строку, столбец и
последний ключ; поле `Message` в сообщение не брать
- [x] 1а.3 Прочие отказы декодера — взять текст как есть: он собран из имён
ключей и типов. Причину разреза записать комментарием, иначе следующая
правка сведёт две ветки в одну
- [x] 1а.4 Проверить тестом на временном файле: строка `bot_token` с оборванной
кавычкой даёт отказ, в тексте которого нет ни одной подстроки значения,
но есть номер строки и имя ключа
- [x] 1а.5 Проверить тестом, что несовпадение типов (строка вместо числа)
по-прежнему называет ключ и типы
## 2. Сборка входа
- [x] 2.1 Сменить подпись `buildTelegram` на приём `config.TelegramConfig`
целиком и поправить вызов в `main.go`
- [x] 2.2 Вынести решение в `telegramFromConfig(cfg, newBot, logger)`, где
`newBot` — параметр-функция сборки клиента; `buildTelegram` подставляет
`telegram.NewBot`. Ветка выключенного входа стоит **до** вызова `newBot`:
выставляет признак поднятости в ноль, пишет одну строку уровня `INFO` и
возвращает заглушку отправителя
- [x] 2.3 Свести в `telegramFromBot` ветку `telegram.ErrEmptyToken` с веткой
отказа Bot API: оба исхода — ошибка настройки, старт роняется
- [x] 2.4 Обновить комментарий-разрез над `telegramFromBot`: он описывает три
исхода по прежнему разрезу
## 3. Проверки поведения
- [x] 3.1 Заменить тест `TestTelegramFromBotOnEmptyTokenGivesAbsentSender`
проверкой нового исхода: пустой ключ при включённом входе роняет старт,
заглушка не подставляется
- [x] 3.2 Написать тест на выключенный вход через `telegramFromConfig`: старт не
падает, ядро получает заглушку, в журнале ровно одна запись уровня `INFO`
- [x] 3.3 Проверить главное утверждение выключенного входа **счётчиком, а не
исходом**: `telegramFromConfig` с `Enabled = false` и заполненным
(заведомо ненастоящим) ключом зовёт подставную сборку **ноль раз**. Судить
по «бот не заведён, отказа нет» нельзя: эти утверждения остаются верными и
тогда, когда ветка встала после обращения, а обращение ушло в живой
Telegram
- [x] 3.4 Оставшиеся тесты `telegramFromBot` (отказ Bot API, недоступность,
живой бот) прогнать без правок по существу
## 4. Настройки и документы
- [x] 4.1 Добавить `enabled` в секцию `[telegram]` образца `config.dist.toml`
со значением `false` и комментарием: зачем поле, что значит каждое
значение, что ключ обязателен и умолчания у него нет
- [x] 4.2 Переписать комментарий к `bot_token` в образце: он больше не отвечает
за включение входа
- [x] 4.3 Поправить запрет «Боевым токеном бота не запускаться» в `CLAUDE.md`:
локальный прогон идёт с `enabled = false`, а не с пустым токеном
- [x] 4.0 Записать в `docs/conventions/config.md`, раздел «Секреты», правило
«отказ загрузки настроек не несёт содержимого файла» с причиной: текст
отказа собирает чужая библиотека, и разбираемый кусок попадает в него
целиком
- [x] 4.4 Поправить `docs/conventions/config.md` в **двух** разделах: «Проверка
и остановка на старте» описывает прежний разрез по пустоте токена, а
«Структура в коде» утверждает, что новое поле требует правки обоих мест,
включая `defaultConfig()`. Записать там форму обязательного поля без
умолчания, иначе следующий такой ключ получит угаданное намерение обратно
- [x] 4.5 Поправить `docs/architecture.md` в **двух** местах: строку перечня
capability («незаданный вход Telegram не мешает подъёму» — после изменения
ложно и ссылается на снятое имя требования) и строку про Telegram в
таблице отказов
- [x] 4.6 Поправить `docs/review.md`: рецепт живого прогона там велит поднимать
сервис с пустым `telegram.bot_token`, а после изменения так он не встанет
- [ ] 4.7 Завести ADR о том, что намерение объявляется признаком, а не выводится
из ключа доступа, и проставить парный статус
`ADR-2026-08-13-telegram-outage-does-not-block-startup`: он утверждает, что
старт роняет ровно один исход сборки клиента, а после изменения их два.
Заводить через скилл `av-dev:doc-sync`, на шаге синка документации
## 5. Гейт и живой прогон
- [ ] 5.1 `task gate` зелёный — **не выполнено, и причина не в этой работе**:
шаги `docs` и `tasks` красные оба по одной причине — проект приведён к
раскладке av-dev версии 3, а плагин ждёт версии 4. Проверено на чистом
`HEAD` в отдельном рабочем дереве: там те же два шага и то же
расхождение. Чинится операцией `upgrade` скилла `av-dev:canon`, и это
отдельная работа. Прочие шаги гейта зелёные
- [x] 5.2 Живой прогон: подъём с `enabled = false` — сервис встаёт, в журнале
одна запись `INFO`, признак поднятости входа Telegram равен нулю
- [x] 5.3 Живой прогон: подъём с `enabled = true` и пустым `bot_token` — процесс
выходит с ненулевым кодом, сообщение называет ключ
- [x] 5.4 Живой прогон: подъём с секцией `[telegram]` без ключа `enabled`
процесс выходит с ненулевым кодом, сообщение называет недостающий ключ
+3 -2
View File
@@ -32,12 +32,13 @@ context: |
- docs/architecture.md — устройство; docs/security.md — периметр; - docs/architecture.md — устройство; docs/security.md — периметр;
docs/database.md — схема и настройки с числами; docs/adr/ — почему решено docs/database.md — схема и настройки с числами; docs/adr/ — почему решено
так; docs/research/ — что уже измерено; так; docs/research/ — что уже измерено;
- tasks/ROADMAP.md — что приложение уже умеет и чего ещё не умеет. - openspec/specs/ — что приложение уже умеет; tasks/BACKLOG.md — что осталось
и в каком порядке это берут.
Пересказа этих документов здесь нет намеренно: второй дом факта расходится с Пересказа этих документов здесь нет намеренно: второй дом факта расходится с
первым молча, и заметно это становится в предложении, которое уже написано. первым молча, и заметно это становится в предложении, которое уже написано.
Ревью: правило выбора метки и состав проходов здесь не пересказываем — их дом Ревью: правило выбора метки и состав проходов здесь не пересказываем — их дом
скилл av-dev-code:review, проектная настройка — docs/review.md. скилл av-dev:code-review, проектная настройка — docs/review.md.
Конвенции кода: механизированное проверяет гейт, прозой остаётся Конвенции кода: механизированное проверяет гейт, прозой остаётся
docs/conventions/. Ни состав шагов гейта, ни перечень конвенций здесь не docs/conventions/. Ни состав шагов гейта, ни перечень конвенций здесь не
+124 -5
View File
@@ -4,12 +4,14 @@
Приём записи и опрос готовности задачи расшифровки: что считается принятой Приём записи и опрос готовности задачи расшифровки: что считается принятой
записью, что уезжает в ответ и что происходит, когда запись не удалось записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Описан пока **только приём по HTTP** — тот, что нормируют проверки. Приём из Приём по существу описан пока **только для HTTP** — того, что нормируют
Telegram делит с ним общий шаг заведения задачи, но требований на него нет: проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
требование, написанное без проверки, — предположение, а не норма. Первая задача, следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
которая трогает поведение приёма из Telegram, дописывает его сюда. требованиями по-прежнему не описано — требование, написанное без проверки, это
предположение, а не норма. Первая задача, которая трогает поведение приёма из
Telegram, дописывает его сюда.
## Requirements ## Requirements
### Requirement: Приём записи по HTTP ### Requirement: Приём записи по HTTP
@@ -256,3 +258,120 @@ Telegram делит с ним общий шаг заведения задачи,
- **WHEN** программа спрашивает состояние по неизвестному идентификатору - **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче - **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
вход и неподнятый.
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
единственный канал наблюдения, который у владельца автоматизирован.
#### Scenario: Вход Telegram не поднят
- **GIVEN** сервис поднялся без Telegram
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости входа Telegram равен нулю
- **AND** признак поднятости входа HTTP равен единице
### Requirement: Признак включения решает, поднимается ли вход Telegram
Намерение владельца SHALL объявляться отдельным признаком включения входа
Telegram, а ключ доступа MUST означать только доступ. При выключенном входе
сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе.
При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя
незаполненного ключа и MUST не нести его значения.
Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где
признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав
недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён
затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок
тихой — либо бот молча пропадает, либо файл без признака молча работает.
Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO`
при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем;
предупреждение остаётся за тем, чего владелец не выбирал.
При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и
MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и
конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в
журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без
значения ключа.
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы
неоткуда.
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
здоровья.
Требование нормирует **наличие входа**, а не приём из него.
#### Scenario: Вход выключен
- **GIVEN** в настройках сервиса вход Telegram выключен
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** конвейер расшифровки работает
- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что
вход выключен настройкой
#### Scenario: Вход выключен, а ключ доступа задан
- **GIVEN** в настройках сервиса вход Telegram выключен
- **AND** ключ доступа при этом заполнен
- **WHEN** сервис запускается
- **THEN** он поднимается без Telegram, и бот не заводится
- **AND** к Telegram не уходит ни одного обращения
#### Scenario: Вход включён, а ключа доступа нет
- **GIVEN** в настройках сервиса вход Telegram включён
- **AND** ключ доступа пуст
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** сообщение об отказе называет имя незаполненного ключа
#### Scenario: Признака включения в настройках нет
- **GIVEN** в настройках сервиса нет признака включения входа Telegram
- **AND** ключ доступа заполнен и Telegram признаёт по нему бота
- **WHEN** сервис запускается
- **THEN** старт кончается отказом настройки
- **AND** сообщение об отказе называет недостающий ключ
#### Scenario: Вход включён и ключ годен
- **GIVEN** в настройках сервиса вход Telegram включён
- **AND** стоит ключ, по которому Telegram признаёт бота
- **WHEN** сервис запускается
- **THEN** он поднимается и работает обоими входами
#### Scenario: Telegram не отвечает
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
- **AND** запись не несёт значения ключа
#### Scenario: Telegram ответил, что такого бота нет
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
- **AND** Telegram отвечает отказом на этот ключ
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** ни журнал, ни текст отказа не несут значения ключа
+77 -12
View File
@@ -3,16 +3,19 @@
## Purpose ## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер, Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, и что считается отказом шага. когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
когда доставить его некуда.
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
результата держателем захвата и недоставка ответа при неподнятом входе.
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
Описан пока **только пустой прогон воркера** — тот, что нормируют проверки
пакета `internal/controller/worker` и перевод признака в `internal/service`.
Сознательно не описаны переходы состояний и цепочка `created → converted →
transcribe → done | failed`, захват задачи и срок его протухания, отмена
контекста посреди шага, освобождение ресурсов внешних клиентов. Это не значит,
что такого поведения нет: оно живёт в коде, а требования на него не написаны,
потому что требование без проверки — предположение, а не норма. Первая задача,
которая трогает любое из перечисленного, дописывает его сюда.
## Requirements ## Requirements
### Requirement: Пустой прогон воркера — не отказ ### Requirement: Пустой прогон воркера — не отказ
@@ -22,9 +25,9 @@ transcribe → done | failed`, захват задачи и срок его пр
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
пояснения, добавленные к этому значению на любом промежуточном шаге пути. пояснения, добавленные к этому значению на любом промежуточном шаге пути.
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: три воркера Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: воркеры
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт три опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от
записи отказа в секунду и столько же засчитанных сбоев, которых не было. каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было.
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
@@ -260,3 +263,65 @@ MUST расти с числом её попыток до объявленног
- **THEN** задержка до следующей проверки каждый раз одна и та же - **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число попыток задачи не растёт - **AND** число попыток задачи не растёт
### Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
меткой.
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят**
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
журнала, а не в состоянии задачи.
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
запись MUST не попадать — приватность содержимого записи требование не
ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
#### Scenario: Вход отправителя не поднят
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
- **AND** сервис поднялся без этого входа
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
`failed` не переводится
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
задачи и причиной
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
#### Scenario: Адресат у задачи не назван
- **GIVEN** у задачи источником значится Telegram, а чат не назван
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
задачи и причиной: неназванный адресат — симптом порчи записи
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- **GIVEN** задача принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке
-241
View File
@@ -1,241 +0,0 @@
# toolchain Specification
## Purpose
Каким инструментом и какой его версии собирается сервис, и что об этом
проверяется до выкладки. Заведена задачей `go-1-26-upgrade` 2026-08-12 по
дефекту, записанному в `docs/review.md` за то же число: сборочный образ разошёлся
с требованием модуля, образ перестал собираться, а восемь шагов гейта и шесть
проходов ревью показали зелёное.
Capability нормирует **не поведение сервиса** для его потребителей, а поведение
инструмента разработки; потребитель у неё другой — тот, кто собирает сервис. Это
осознанное исключение, и оно названо в преамбуле `docs/architecture.md`.
## Requirements
### Requirement: Версия инструмента сборки объявлена одним числом
Проект SHALL объявлять версию Go, на которой собирается сервис, одинаково во
всех местах, где она названа. Мест ровно четыре, и перечень закрыт: требование
модуля в `go.mod`, сборочный образ в `Dockerfile`, строка стека в `CLAUDE.md`,
строка стека в `README.md`.
Сравниваются мажор и минор. Третье число у сборочного образа MUST оставаться
свободным, как и база образа: образ обновляется своим темпом, и требовать от
него совпадения по патчу значило бы краснеть на каждом его обновлении. Тег
читается по форме `golang:<мажор>.<минор>[.<патч>][-<база>]`, и берутся из него
первые два числа.
Правило множественности у мест разное, потому что места устроены по-разному.
**Документы** — `CLAUDE.md` и `README.md` — MUST называть версию ровно один раз,
и считается это **не по файлу, а по разделу стека**: `## Стек` в памятке,
`## Технологии` в README. Второе вхождение числа **в этом разделе** MUST
считаться отказом: обновят одно, второе протухнет молча. За пределами раздела
число не читается вовсе — иначе памятка, которая по устройству ведёт историю
закрытых долгов, роняла бы проверку на первой же правдивой строке о прошлой
версии, а сообщение толкало бы чинить не проверку, а исторический документ.
**Сборочный образ** единственности не требует: каждый слой — настоящий вход
сборки, и многослойная сборка законна. От всех вхождений `FROM golang:` MUST
требоваться совпадение мажора и минора, а не единственность.
**Требование модуля** называется директивой `go` и по устройству файла
единственно.
Граница раздела MUST быть определена, а не подразумеваться: раздел кончается
следующим заголовком того же или более высокого уровня, заголовок третьего уровня
и ниже остаётся внутри раздела, а строка, похожая на заголовок, но лежащая внутри
блока кода, заголовком MUST не считаться. Без этого пример в чужом разделе
открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда
норма его читать не велит.
`go.mod` MUST не содержать директиву `toolchain`. Она называет версию **пятым**
местом, которого перечень не знает: при `toolchain go1.27.0` четыре объявленных
числа сойдутся, а собирать будет пятое — то есть вернётся тот самый класс
расхождения, ради которого требование и заведено.
#### Scenario: Все четыре места названы одинаково
- **GIVEN** дерево проекта, где `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md`
называют версию Go
- **WHEN** их читают подряд
- **THEN** мажор и минор совпадают во всех четырёх
#### Scenario: Патч сборочного образа отличается законно
- **GIVEN** `go.mod` требует `1.26.0`, а образ собирается на `golang:1.26.5-alpine`
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: База сборочного образа сменилась
- **GIVEN** образ переехал с `golang:1.26-alpine` на `golang:1.26-bookworm`
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: Раздел стека называет версию дважды
- **GIVEN** раздел стека в `CLAUDE.md` называет версию два раза
- **WHEN** версии сравнивают
- **THEN** это расхождение, даже если оба числа одинаковы
#### Scenario: Число за пределами раздела стека не читается
- **GIVEN** `CLAUDE.md` вне раздела стека упоминает прошлую версию Go — например
записью о закрытом долге
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: Сборочный образ собран в два слоя
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с одним мажором и минором
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: Слои сборочного образа разошлись между собой
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с разными минорами
- **WHEN** версии сравнивают
- **THEN** это расхождение
#### Scenario: Заголовок раздела встретился внутри блока кода
- **GIVEN** документ в чужом разделе показывает пример, внутри которого есть
строка, совпадающая с заголовком раздела стека, а ниже названо другое число
- **WHEN** версии сравнивают
- **THEN** число из примера не читается, и расхождением это не считается
#### Scenario: Раздел стека закрыт заголовком верхнего уровня
- **GIVEN** после раздела стека идёт заголовок первого уровня, а ниже названа
прошлая версия
- **WHEN** версии сравнивают
- **THEN** это число не читается, и расхождением не считается
#### Scenario: Раздела стека нет вовсе
- **GIVEN** в документе нет раздела, где называется версия
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет недостающий раздел
#### Scenario: Модуль объявляет версию пятым местом
- **GIVEN** `go.mod` содержит директиву `toolchain`
- **WHEN** версии сравнивают
- **THEN** это расхождение
### Requirement: Объявленное число — то, на котором проект собирается
Объявленная версия SHALL быть той, на которой сервис действительно собирается и
проходит тесты. Согласованность четырёх строк между собой этого не доказывает:
четыре одинаковых числа несуществующей версии требованию о согласованности
удовлетворяют, а собрать на них нельзя.
Проверка эта MUST оставаться за человеком и MUST не входить в набор проверок:
она требует сборки образа, а сборка образа набором проверок не делается
намеренно — дорого. Подъём версии MUST не уезжать в основную ветку, пока сборка
образа и тесты на объявленном числе не прогнаны.
#### Scenario: Версию подняли
- **GIVEN** объявленную версию Go подняли во всех четырёх местах
- **WHEN** изменение готовят к мерджу
- **THEN** до мерджа на этой версии прогнаны сборка образа и тесты
### Requirement: Расхождение версий роняет набор проверок
Набор проверок `task gate` SHALL включать шаг, который сравнивает объявленные
версии между собой и MUST завершаться отказом, когда они разошлись. Сообщение
отказа MUST называть **все четыре места и прочитанное в каждом число** — не одну
разошедшуюся пару: в дефекте 2026-08-12 три места из четырёх говорили одно и то
же и неверными были именно они, а по сообщению о паре человек чинит не то место.
Шаг MUST судить по содержимому файлов репозитория и MUST не спрашивать
установленный инструмент — ни `go version`, ни `go env`, ни `GOTOOLCHAIN`. Исход
его MUST быть функцией коммита, а не машины: шаг, чей ответ зависит от того, что
стоит на хосте, воспроизводит ровно ту подмену, которая держала дефект
2026-08-12 невидимым — там `go build ./...` шёл на хостовом Go, а объявленное
число не проверял никто.
Шаг MUST работать сравнением строк — без сборки образа, без docker и без сети —
и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только
читать: файлов он не правит и разошедшихся мест не чинит.
Коды выхода MUST следовать общему словарю проверочных шагов проекта; словарь
объявляет раздел «Гейт» в `CLAUDE.md`, и здесь он не повторяется. Своего словаря шаг
MUST не заводить: четвёртый шаг с собственной семантикой сделал бы это
утверждение неверным.
Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места.
«Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы
как совпадение.
Отказ чтения места MUST не выглядеть как отсутствие числа. Место, которое
существует, но не читается, — это отказ окружения, и сообщение MUST говорить о
нечитаемости, а не о ненайденной версии: иначе шаг отправляет чинить документ, в
котором строка на месте, а сломаны права.
#### Scenario: Разошёлся сборочный образ
- **GIVEN** `Dockerfile` называет версию, отличную от прочих трёх мест
- **WHEN** запускают `task gate`
- **THEN** шаг сверки завершается отказом
- **AND** сообщение называет все четыре места и число каждого
- **AND** весь набор проверок краснеет
#### Scenario: Разошлось требование модуля
- **GIVEN** `go.mod` называет версию, отличную от прочих трёх мест
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет `go.mod` среди разошедшихся
#### Scenario: Разошлась памятка
- **GIVEN** `CLAUDE.md` называет версию, отличную от прочих трёх мест
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет `CLAUDE.md` среди разошедшихся
#### Scenario: Разошёлся README
- **GIVEN** `README.md` называет версию, отличную от прочих трёх мест
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет `README.md` среди разошедшихся
#### Scenario: Версии совпадают
- **GIVEN** все четыре места называют одно число
- **WHEN** запускают `task gate`
- **THEN** шаг сверки проходит с кодом 0
- **AND** остальные шаги набора идут как прежде
#### Scenario: Инструмента сборки нет на машине
- **GIVEN** в `PATH` нет `go` вовсе
- **WHEN** запускают шаг сверки
- **THEN** исход и сообщение те же, что и при установленном `go`
#### Scenario: Ни docker, ни сети нет
- **GIVEN** docker недоступен и сети нет
- **WHEN** запускают шаг сверки
- **THEN** он отрабатывает и даёт тот же исход, что и при доступном docker
#### Scenario: Шаг запущен не из корня проекта
- **GIVEN** шаг запускают из подкаталога дерева
- **WHEN** он ищет свои четыре места
- **THEN** исход тот же, что и при запуске из корня
#### Scenario: Место существует, но не читается
- **GIVEN** файл одного из мест на диске есть, но прав на чтение нет
- **WHEN** запускают шаг сверки
- **THEN** он завершается кодом окружения и говорит о нечитаемости места
- **AND** сообщения «версия не названа» не печатает
#### Scenario: Версия не названа там, где должна быть
- **GIVEN** одно из четырёх мест перестало называть версию Go
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет место, где число не нашлось
-358
View File
@@ -1,358 +0,0 @@
// Package scripts — проверки скриптов репозитория. Рабочего кода на Go в нём
// нет: пакет существует ради того, чтобы `go test ./...` гонял и shell.
//
// Норма шага сверки версий — openspec/specs/toolchain/spec.md. Каждый её
// сценарий проверяется здесь мутацией: дерево-образец собирается во временном
// каталоге, портится ровно одним способом, и от скрипта требуется объявленный
// исход. Прежде сценарии подтверждались разовыми ручными прогонами — после
// первой правки образца они перестали бы выполняться молча.
package scripts
import (
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
// Дерево-образец: все четыре места называют одну версию.
//
// `CLAUDE.md` держит второе число **за** разделом стека намеренно: так выглядит
// правдивая строка о закрытом долге, и норма велит её не читать.
var fixture = map[string]string{
"go.mod": "module example\n\ngo 1.26.0\n",
"Dockerfile": "FROM docker.io/library/golang:1.26-alpine AS builder\n" +
"RUN true\n\n" +
"FROM docker.io/library/alpine:3.22\n",
"CLAUDE.md": "# CLAUDE.md\n\n" +
"## Стек\n\nGo 1.26, встроенная PocketBase.\n\n" +
"## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n",
"README.md": "# transcriber\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n",
}
// allPlaces — все четыре места и число каждого: этого требует норма от
// сообщения о расхождении. Числа два, потому что разошедшееся место называет
// своё.
var allPlaces = []string{"go.mod", "Dockerfile", "CLAUDE.md", "README.md", "1.26", "1.25"}
const (
exitOK = 0
exitDrift = 1
exitUsage = 2
exitEnviron = 3
)
func TestСверкаВерсийПоСценариямНормы(t *testing.T) {
cases := []struct {
name string
// mutate портит дерево-образец; nil — дерево не портится.
mutate func(t *testing.T, root string)
// args — аргументы скрипта.
args []string
// dir — рабочий каталог прогона относительно корня дерева.
dir string
want int
// says — что обязано прозвучать в сообщении.
says string
// saysAll — что обязано прозвучать всё разом. Норма требует от сообщения
// о расхождении **все четыре места и число каждого**: в дефекте
// 2026-08-12 три места из четырёх говорили одно и то же, и неверными
// были именно они — по сообщению о паре человек чинит не то место.
saysAll []string
// saysNot — чего в сообщении быть не должно.
saysNot string
}{
{name: "все четыре места названы одинаково", want: exitOK},
{
name: "патч сборочного образа отличается законно",
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26.5-alpine"),
want: exitOK,
},
{
name: "база сборочного образа сменилась",
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26-bookworm"),
want: exitOK,
},
{
name: "сборочный образ собран в два слоя",
mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.26-alpine AS tools"),
want: exitOK,
},
{
name: "слои сборочного образа разошлись между собой",
mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.25-alpine AS tools"),
want: exitDrift,
says: "Dockerfile",
},
{
name: "разошёлся сборочный образ",
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.25-alpine"),
want: exitDrift,
says: "разошлись",
saysAll: allPlaces,
},
{
name: "разошлось требование модуля",
mutate: replace("go.mod", "go 1.26.0", "go 1.25.0"),
want: exitDrift,
says: "разошлись",
saysAll: allPlaces,
},
{
name: "разошлась памятка",
mutate: replace("CLAUDE.md", "Go 1.26, встроенная", "Go 1.25, встроенная"),
want: exitDrift,
says: "разошлись",
saysAll: allPlaces,
},
{
name: "разошёлся README",
mutate: replace("README.md", "Go 1.26 и ffmpeg", "Go 1.25 и ffmpeg"),
want: exitDrift,
says: "разошлись",
saysAll: allPlaces,
},
{
name: "раздел стека называет версию дважды",
mutate: replace("CLAUDE.md", "встроенная PocketBase.", "встроенная PocketBase, всё та же Go 1.26."),
want: exitDrift,
says: "больше одного раза",
},
{
name: "число за пределами раздела стека не читается",
mutate: replace("CLAUDE.md", "Go 1.24 — долг закрыт.", "Go 1.24 и Go 1.23 — долги закрыты."),
want: exitOK,
},
{
name: "заголовок раздела встретился внутри блока кода",
mutate: replace("README.md", "## Технологии\n\nGo 1.26 и ffmpeg.\n",
"## Пример\n\n```md\n## Технологии\n\nGo 1.19 из примера.\n```\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n"),
want: exitOK,
},
{
name: "раздел стека закрыт заголовком верхнего уровня",
mutate: replace("CLAUDE.md", "## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n",
"# Приложение\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n"),
want: exitOK,
},
{
name: "раздела стека нет вовсе",
mutate: replace("CLAUDE.md", "## Стек", "## Инструменты"),
want: exitDrift,
says: "нет раздела",
},
{
name: "версия не названа там, где должна быть",
mutate: replace("README.md", "Go 1.26 и ffmpeg.", "ffmpeg и всё остальное."),
want: exitDrift,
says: "не называет версию",
},
{
name: "модуль объявляет версию пятым местом",
mutate: replace("go.mod", "go 1.26.0", "go 1.26.0\n\ntoolchain go1.27.0"),
want: exitDrift,
says: "toolchain",
},
{
name: "места нет вовсе",
mutate: remove("README.md"),
want: exitEnviron,
says: "нет файла",
},
{
name: "место существует, но не читается",
mutate: unreadable("README.md"),
want: exitEnviron,
says: "нечитаем",
saysNot: "не называет версию",
},
{
name: "шаг запущен не из корня проекта",
dir: "scripts",
want: exitOK,
},
{
name: "шагу переданы аргументы",
args: []string{"--base", "origin/master"},
want: exitUsage,
says: "Использование",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
root := treeWithScript(t)
if c.mutate != nil {
c.mutate(t, root)
}
code, out := runScript(t, root, c.dir, c.args)
if code != c.want {
t.Errorf("код возврата %d, ожидался %d\nвывод:\n%s", code, c.want, out)
}
if c.says != "" && !strings.Contains(out, c.says) {
t.Errorf("в сообщении нет %q\nвывод:\n%s", c.says, out)
}
for _, want := range c.saysAll {
if !strings.Contains(out, want) {
t.Errorf("сообщение не называет %q\nвывод:\n%s", want, out)
}
}
if c.saysNot != "" && strings.Contains(out, c.saysNot) {
t.Errorf("в сообщении есть лишнее %q\nвывод:\n%s", c.saysNot, out)
}
})
}
}
// Норма требует, чтобы исход был функцией коммита, а не машины: скрипт не
// спрашивает установленный инструмент. Проверяется это прогоном без `go` в
// `PATH` — исход обязан не измениться.
func TestИсходНеЗависитОтУстановленногоGo(t *testing.T) {
root := treeWithScript(t)
withGo, outWith := runScript(t, root, "", nil)
if withGo != exitOK {
t.Fatalf("дерево-образец обязано сходиться, а код %d:\n%s", withGo, outWith)
}
goBin, err := exec.LookPath("go")
if err != nil {
t.Skip("go не найден в PATH — проверять нечего")
}
var kept []string
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
if dir != filepath.Dir(goBin) {
kept = append(kept, dir)
}
}
withoutGo, outWithout := runScript(t, root, "", nil, "PATH="+strings.Join(kept, string(os.PathListSeparator)))
if withoutGo != withGo {
t.Errorf("без go в PATH код %d, с ним %d\nвывод:\n%s", withoutGo, withGo, outWithout)
}
}
// Скрипт не зовёт ни `go`, ни `docker`, ни сеть — это читается из его текста, и
// правило держит именно текст: прогон без сети в наборе проверок недоступен.
func TestСкриптНеЗоветНиGoНиDocker(t *testing.T) {
body, err := os.ReadFile("check-go-version.sh")
if err != nil {
t.Fatalf("читаю скрипт: %v", err)
}
// Границы слова обязательны: имя `read_dockerfile` и переменная `dockerfile`
// законны — читается файл, а не зовётся демон.
code := withoutComments(string(body))
for _, forbidden := range []string{`go\s+version`, `go\s+env`, `GOTOOLCHAIN`, `\bdocker\b`, `\bcurl\b`, `\bwget\b`} {
if regexp.MustCompile(forbidden).FindString(code) != "" {
t.Errorf("скрипт зовёт %s вне комментария: исход перестаёт быть функцией коммита", forbidden)
}
}
}
// --- Помощники --------------------------------------------------------------
// treeWithScript собирает дерево-образец и кладёт в него сам скрипт: корень он
// считает от своего расположения, поэтому проверяется копия внутри дерева.
func treeWithScript(t *testing.T) string {
t.Helper()
root := t.TempDir()
for name, body := range fixture {
write(t, filepath.Join(root, name), body, 0o644)
}
script, err := os.ReadFile("check-go-version.sh")
if err != nil {
t.Fatalf("читаю скрипт: %v", err)
}
if err := os.Mkdir(filepath.Join(root, "scripts"), 0o755); err != nil {
t.Fatalf("завожу каталог scripts: %v", err)
}
write(t, filepath.Join(root, "scripts", "check-go-version.sh"), string(script), 0o755)
return root
}
// runScript гоняет скрипт и отдаёт код возврата с объединённым выводом.
// `env` — добавка к окружению прогона, `args` — аргументы скрипта.
func runScript(t *testing.T, root, dir string, args []string, env ...string) (int, string) {
t.Helper()
// Контекст проверки: зависший скрипт умирает вместе с ней, а не переживает
// прогон осиротевшим процессом.
cmd := exec.CommandContext(t.Context(), "sh", append([]string{filepath.Join(root, "scripts", "check-go-version.sh")}, args...)...)
cmd.Dir = filepath.Join(root, dir)
if len(env) > 0 {
cmd.Env = append(os.Environ(), env...)
}
out, err := cmd.CombinedOutput()
code := 0
if err != nil {
var exit *exec.ExitError
if !errors.As(err, &exit) {
t.Fatalf("прогон скрипта: %v", err)
}
code = exit.ExitCode()
}
return code, string(out)
}
func replace(file, old, new string) func(*testing.T, string) {
return func(t *testing.T, root string) {
t.Helper()
path := filepath.Join(root, file)
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("читаю %s: %v", file, err)
}
if !strings.Contains(string(body), old) {
t.Fatalf("в образце %s нет %q: мутация потеряла предмет", file, old)
}
write(t, path, strings.Replace(string(body), old, new, 1), 0o644)
}
}
func remove(file string) func(*testing.T, string) {
return func(t *testing.T, root string) {
t.Helper()
if err := os.Remove(filepath.Join(root, file)); err != nil {
t.Fatalf("убираю %s: %v", file, err)
}
}
}
func unreadable(file string) func(*testing.T, string) {
return func(t *testing.T, root string) {
t.Helper()
path := filepath.Join(root, file)
if err := os.Chmod(path, 0o000); err != nil {
t.Fatalf("снимаю права с %s: %v", file, err)
}
// Права возвращаются, иначе уборка временного каталога отказала бы.
t.Cleanup(func() {
if err := os.Chmod(path, 0o644); err != nil {
t.Errorf("возвращаю права %s: %v", file, err)
}
})
if body, err := os.ReadFile(path); err == nil {
t.Skipf("файл читается и без прав (%d байт) — прогон под root?", len(body))
}
}
}
func write(t *testing.T, path, body string, perm os.FileMode) {
t.Helper()
if err := os.WriteFile(path, []byte(body), perm); err != nil {
t.Fatalf("пишу %s: %v", path, err)
}
}
// withoutComments снимает строки-комментарии: слово в объяснении вызовом не
// является, а объяснения в этом скрипте длиннее самого кода.
func withoutComments(body string) string {
var kept []string
for line := range strings.SplitSeq(body, "\n") {
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
kept = append(kept, line)
}
}
return strings.Join(kept, "\n")
}
-3
View File
@@ -1,3 +0,0 @@
{
"tasks": 1
}
+25 -28
View File
@@ -1,23 +1,27 @@
# Беклог # Беклог
Что **можно взять**. Одна задача = один файл `items/<slug>.md` Что **можно взять**. Одна задача = один файл `items/<slug>.md`
+ строка здесь. Целей тут нет — они в [ROADMAP.md](ROADMAP.md): беклог — то, что берут, + строка здесь. Ведётся скиллом `av-dev:task-track`.
роадмап — то, подо что берут. **Порядок строк значим:**
это очередь, и первая строка — то, что делают следующим. Порядок
назначает человек на груминге, машина его не выводит. Одно исключение
производно от типа — сырьё (`research` без раздела «Вопрос»)
стоит в конце: его не берут. Ведётся скиллом `tasks`.
Секция одна — полок домена у проекта нет, и делить очередь на две <!-- стадия -->
значило бы держать два порядка вместо одного. Стадия проекта — **стройка** (`[tasks] stage = "build"`).
**Порядок строк — зависимость:** это план стройки от базы к деталям,
и строка выше сделана раньше не потому, что важнее, а потому, что
иначе нельзя. Секция здесь **одна**: разложенный по полкам план
перестаёт быть планом. Список пишется вперёд целиком — это не
гниение беклога, а замысел. Пустой беклог значит, что стройка
окончена: дальше `tasks.py stage support`.
<!-- /стадия -->
**Чем очередь упорядочена на этом этапе — от базы к деталям.** **Чем основание отличается от детали в этом проекте.** Сначала идёт то,
Сначала то, на чём стоит остальное: проверки, которым можно верить, на чём стоит остальное: проверки, которым можно верить, владелец записи,
владелец записи, единый контракт API, покрытый тестами конвейер, — и единый контракт API, покрытый тестами конвейер, — и только потом экраны
только потом экраны и возможности поверх них. Порядок расставлен на и возможности поверх них. Этот порядок расставили 2026-08-12.
груминге 2026-08-12 и держится, пока сервис не собран целиком: Задача, взятая раньше своего основания, стоит дважды: сперва её пишут,
задача, взятая раньше своего основания, стоит дважды — сперва её потом переписывают под появившееся основание.
пишут, потом переписывают под появившееся основание.
Одно место в очереди назначено не человеком, а типом: сырьё
(`research` без раздела «Вопрос») стоит в конце секции — его не берут.
Отсюда правило для **новых** записей. Заведённая по ходу работы — Отсюда правило для **новых** записей. Заведённая по ходу работы —
интейком, урожаем ревью, разбором находок — задача встаёт в конец интейком, урожаем ревью, разбором находок — задача встаёт в конец
@@ -39,16 +43,15 @@
## Очередь ## Очередь
- [🧹 Ронять гейт на изменённой функции, которую не выполняет ни один тест](items/gate-changed-lines-coverage.md) — Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
- [🧹 Поднимать сервис локально без действующего токена бота](items/local-run-without-telegram-token.md) — Адаптер Telegram проверяет токен обращением к Telegram и роняет старт, а боевым токеном запускаться запрещено: проверить поведение живым прогоном не может ни одна задача.
- [🐞 Убрать код провайдера из журнала запросов хранилища](items/provider-code-out-of-storage-log.md) — Строка запроса с кодом входа целиком уезжает в таблицу _logs и лежит там пять суток, хотя спека access требует, чтобы код в журнал не попадал. - [🐞 Убрать код провайдера из журнала запросов хранилища](items/provider-code-out-of-storage-log.md) — Строка запроса с кодом входа целиком уезжает в таблицу _logs и лежит там пять суток, хотя спека access требует, чтобы код в журнал не попадал.
- [🐞 Вести учёт употреблённых состояний входа на сервере](items/server-side-login-state.md) — Одноразовость возврата держится на уборке куки, то есть на браузере: сервер не помнит, какие состояния уже потрачены. - [🐞 Вести учёт употреблённых состояний входа на сервере](items/server-side-login-state.md) — Одноразовость возврата держится на уборке куки, то есть на браузере: сервер не помнит, какие состояния уже потрачены.
- [🧹 Строить адрес входа из настроек коллекции, а не из конфига](items/login-url-from-collection-settings.md) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину. - [ Строить адрес входа из настроек коллекции, а не из конфига](items/login-url-from-collection-settings.md) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
- [🔬 Четыре недоказанные гипотезы о поверхности входа](items/login-surface-hypotheses.md) — Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было. - [🔬 Четыре недоказанные гипотезы о поверхности входа](items/login-surface-hypotheses.md) — Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было.
- [🧹 Назвать в необратимом, что откат кода не откатывает шаг схемы](items/rollback-does-not-undo-schema-step.md) — Откат бинаря оставляет применённый шаг схемы в силе, и на этом строятся решения о выкладке: сегодня об этом не сказано нигде. - [🐞 Починить срок сессии, который ставит откат шага входа](items/rollback-restores-wrong-session-duration.md) — Константа defaultAuthTokenDuration в шаге 202608120001 названа умолчанием библиотеки, но 1209600 — это 14 суток, а умолчание PocketBase 432000, пять суток: откат объявляет возврат к умолчанию и ставит срок вдвое больше выбранных владельцем семи.
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись. - [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом. - [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
- [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией. - [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
- [🧹 Свести пять расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; одно сведено при повышении раскладки, а три из пяти оставшихся стоят в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
- [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать. - [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать.
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем. - [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны. - [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
@@ -59,6 +62,7 @@
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен. - [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен.
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками. - [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла. - [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
- [🧹 Закрепить версию рантайм-базы образа](items/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит.
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует. - [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит. - [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем. - [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
@@ -76,7 +80,7 @@
- [✨ Считать только те уровни текста, что включены у владельца записи](items/settings-applied-in-pipeline.md) — Дом настроек есть, а конвейер их не читает: выключенный уровень всё равно уходит платной модели, и настройка ничего не экономит. - [✨ Считать только те уровни текста, что включены у владельца записи](items/settings-applied-in-pipeline.md) — Дом настроек есть, а конвейер их не читает: выключенный уровень всё равно уходит платной модели, и настройка ничего не экономит.
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана. - [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана.
- [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет. - [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается. - [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а работа над долгими записями без них не начинается.
- [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно. - [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
- [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста. - [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста.
- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз. - [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
@@ -91,11 +95,4 @@
- [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа. - [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа.
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего. - [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет. - [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
- [🧹 Закрепить версию рантайм-базы образа](items/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит.
- [🧹 Настроить конвейер ревью по итогам прогона go-1-26-upgrade](items/review-config-from-go-upgrade.md) — Прогон вскрыл две прорехи настройки: «Типовые узлы» знают только рантайм и не знают рода «проверочный шаг набора проверок», а «Триггеры метки» не видят оси «изменение трогает канон» — и именно она дала обе блокирующие находки.
- [🐞 Починить срок сессии, который ставит откат шага входа](items/rollback-restores-wrong-session-duration.md) — Константа defaultAuthTokenDuration в шаге 202608120001 названа умолчанием библиотеки, но 1209600 — это 14 суток, а умолчание PocketBase 432000, пять суток: откат объявляет возврат к умолчанию и ставит срок вдвое больше выбранных владельцем семи.
- [🧹 Проверить шаг гейта migrations так же, как шаг сверки версий Go](items/migrations-step-norm-and-tests.md) — Шаг охраняет critical-инвариант «применённый шаг схемы не переписывается», но своих проверок не имеет: дрейф шаблона имени, переезд каталога или потеря grep в конвейере оставят его вечно зелёным, и это не заметит ничто.
- [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
- [🔬 Шаги гейта, у которых правило может потерять предмет](items/gate-steps-subject-guard.md) — У шага migrations страж предмета есть, у шагов docs, tasks и openspec неизвестно: они зовут чужие скрипты из плагинов, и правило, потерявшее файлы, зеленело бы молча.
- [🧹 Свести шесть расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; четыре из них в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
- [🔬 Квота по общему размеру загруженного на пользователя](items/per-user-size-quota.md) — Паспорт и security.md запрещают отказы по квоте пользователю, а заметка владельца просит квоту по умолчанию 5 ГБ — открытое противоречие с границей домена, которое владелец решил не разбирать сейчас. - [🔬 Квота по общему размеру загруженного на пользователя](items/per-user-size-quota.md) — Паспорт и security.md запрещают отказы по квоте пользователю, а заметка владельца просит квоту по умолчанию 5 ГБ — открытое противоречие с границей домена, которое владелец решил не разбирать сейчас.
+16
View File
@@ -6,3 +6,19 @@
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … --> <!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
- 2026-08-12 `gate-go-version-sync` — 🧹 Сверять версию Go в образе с директивой go.mod. Причина: слита в go-1-26-upgrade 2026-08-12: сверка версии и само обновление правят одни и те же строки go.mod и Dockerfile, и порознь заводят расхождение заново. Была секция: Очередь. - 2026-08-12 `gate-go-version-sync` — 🧹 Сверять версию Go в образе с директивой go.mod. Причина: слита в go-1-26-upgrade 2026-08-12: сверка версии и само обновление правят одни и те же строки go.mod и Dockerfile, и порознь заводят расхождение заново. Была секция: Очередь.
- 2026-08-13 `any-audio-source` — 🎯 Принимается запись любого формата, включая дорожку из видео. Причина: Зонтик над разобранной работой: перечень форматов меряет audio-format-coverage-measure, дорожку из видео берёт video-audio-track-intake. Тип goal упразднён раскладкой av-dev 3. Была секция: Направления.
- 2026-08-13 `data-ownership` — 🎯 Человек убирает свою запись из архива вместе со всеми текстами. Причина: Зонтик над разобранной работой: удаление записи со всеми уровнями текста делает delete-record. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
- 2026-08-13 `long-recordings` — 🎯 Запись длиной до шести часов доходит до текста. Причина: Зонтик над разобранной работой: потолки меряют speechkit-limits и intake-limits-measure, дальше идут reject-oversized-recording, long-audio-chunking и long-text-delivery. Тип goal упразднён раскладкой av-dev 3. Была секция: Направления.
- 2026-08-13 `multi-user` — 🎯 Сервисом пользуются несколько человек, и записи одного не видны другому. Причина: Зонтик над разобранной работой: вход сделан задачей oidc-login, дальше идут record-ownership, telegram-account-link и api-tokens. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
- 2026-08-13 `ready-notification` — 🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым. Причина: Зонтик над разобранной работой: доставку делают ntfy-delivery и email-notification. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
- 2026-08-13 `service-observability` — 🎯 Состояние сервиса видно без чтения логов. Причина: Зонтик над разобранной работой: словарь метрик выбирает opentelemetry-fit, дальше идут stalled-pipeline-metric, external-service-metrics, job-path-by-request и owner-alerting. Тип goal упразднён раскладкой av-dev 3. Была секция: Сопровождение.
- 2026-08-13 `text-insights` — 🎯 Приложение показывает, о чём запись, не читая её целиком. Причина: Зонтик над разобранной работой: уровни текста считает llm-insights-adapter, вычитку даёт literary-text-level, показывает их insights-visible-in-list. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
- 2026-08-13 `upload-reliability` — 🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую. Причина: Зонтик над разобранной работой: дедупликацию делает dedup-by-content-hash, пачку файлов multi-file-upload, ход загрузки upload-progress, уборку за обрывом orphan-file-on-failed-intake. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
- 2026-08-13 `usage-stats` — 🎯 Владелец видит, кто сколько загрузил и во что это обошлось. Причина: Зонтик над разобранной работой: учёт ведёт usage-accounting, показывает его admin-stats-screen. Тип goal упразднён раскладкой av-dev 3. Была секция: Сопровождение.
- 2026-08-13 `user-settings` — 🎯 Пользователь настраивает, что сервис делает с его записями. Причина: Зонтик над разобранной работой: дом настроек заводит settings-screen, читает их в конвейере settings-applied-in-pipeline. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
- 2026-08-13 `web-access` — 🎯 Записи загружаются и читаются в приложении, которое ставится на телефон. Причина: Зонтик над разобранной работой: каркас даёт spa-skeleton, контракт json-api-for-spa, экраны upload-and-status-screen, records-list-screen, play-recording-in-app, установку на телефон installable-pwa. Тип goal упразднён раскладкой av-dev 3. Была секция: Запланировано.
- 2026-08-13 `gate-changed-lines-coverage` — 🧹 Ронять гейт на изменённой функции, которую не выполняет ни один тест. Причина: Владелец отменил 2026-08-13: механизировать покрытие изменённых функций не нужно. Прототип шага гейта откачен, в дерево ничего не уехало. Была секция: Очередь.
- 2026-08-13 `migrations-step-norm-and-tests` — 🧹 Проверить шаг гейта migrations так же, как шаг сверки версий Go. Причина: Владелец отменил 2026-08-13: проверка над проверкой даёт много механики и мало пользы. Сам шаг migrations остаётся и работает — без проверок остаётся только он. Была секция: Очередь.
- 2026-08-13 `gate-steps-subject-guard` — 🔬 Шаги гейта, у которых правило может потерять предмет. Причина: Владелец отменил 2026-08-13: разведка того же класса — проверка над проверками. Ведут ли себя шаги docs, tasks и openspec зелёными без предмета, остаётся неизвестным. Была секция: Очередь.
- 2026-08-13 `review-config-from-go-upgrade` — 🧹 Настроить конвейер ревью по итогам прогона go-1-26-upgrade. Причина: Владелец отменил 2026-08-13: настройка конвейера ревью даёт много механики и мало пользы. Разделы «Типовые узлы» и «Триггеры метки» в docs/review.md остаются как есть. Была секция: Очередь.
- 2026-08-13 `rollback-does-not-undo-schema-step` — 🧹 Назвать в необратимом, что откат кода не откатывает шаг схемы. Причина: Владелец отменил 2026-08-13: задача целиком документационная — одна строка в «Необратимое» о том, что откат бинаря не откатывает шаг схемы. Факт остаётся неназванным нигде. Была секция: Очередь.
-46
View File
@@ -1,46 +0,0 @@
# Роадмап
Состояние проекта: что приложение **уже умеет** и чего ещё не умеет.
Цель — возможность приложения: файл типа `goal` (🎯) в
`items/`. Её задачи здесь **не перечисляются** — перечень даёт
`tasks.py list --goal <слаг>`.
- **Запланировано** — очередь значима и обосновывается прозой;
- **Направления** — очереди нет, тянутся долго;
- **Сопровождение** — чем держат проект: инструмент,
процесс, эксплуатация. Не возможности приложения, и отдельно —
чтобы не читаться как обещание продукта;
- **Готово** — достигнутое: строку пишет
`tasks.py close <цель> --implemented`, ссылки на файл в ней нет —
файл удаляется, поведение живёт в спеках. Стоит последней: копится.
Секции **канонические** и переименованию проектом не подлежат:
у каждой свой смысл, и в достигнутое пишет сам `close`. Порядок
тоже канонический. Английский
вариант — Planned | Directions | Operations | Done, один язык на весь
индекс.
## Запланировано
- [🎯 Сервисом пользуются несколько человек, и записи одного не видны другому](items/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
- [🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую](items/upload-reliability.md) — Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги.
- [🎯 Человек убирает свою запись из архива вместе со всеми текстами](items/data-ownership.md) — Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
- [🎯 Пользователь настраивает, что сервис делает с его записями](items/user-settings.md) — Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем.
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
- [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться.
## Направления
- [🎯 Запись длиной до шести часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
## Сопровождение
- [🎯 Состояние сервиса видно без чтения логов](items/service-observability.md) — Отказ замечает пользователь, а не владелец: оповещения нет, а путь записи по конвейеру собирается глазами по логам контейнера.
- [🎯 Владелец видит, кто сколько загрузил и во что это обошлось](items/usage-stats.md) — Распознавание и языковая модель оплачиваются по факту, а счёт приходит одной суммой: кто её набрал, из сервиса не выясняется.
## Готово
- 2025-08-14 `telegram-transcription` — Голосовое сообщение из Telegram возвращается текстом. Первый вход сервиса: бот принимает голосовое, аудиофайл и документ с аудио и отвечает расшифровкой.
- 2025-08-08 `api-transcription` — Запись, отданная по HTTP, возвращается текстом. Программный вход: файл отдаётся формой, готовность и текст забираются опросом статуса задачи.
+2 -3
View File
@@ -3,10 +3,9 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Страница показывает собранный учёт: без учёта показывать нечего. - **Категория:** Очередь — Страница показывает собранный учёт: без учёта показывать нечего.
- **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет. - **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
- **Теги:** goal:usage-stats
Двигает пункты 1, 2 и 3 «Завершения» цели: расход по каждому пользователю виден Расход по каждому пользователю виден на странице, и открывается она только
на странице, и открывается она только владельцу сервиса. владельцу сервиса.
## Затрагивает ## Затрагивает
-20
View File
@@ -1,20 +0,0 @@
# 🎯 Принимается запись любого формата, включая дорожку из видео
- **Тип:** goal
- **Секция:** Направления — Перечень форматов не замерен, и потолок длины у видео тот же, что у долгих записей: тянется следом за ними.
- **Зачем:** Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
- **Теги:** decomposed
Человек отдаёт файл, не думая о том, что внутри: аудио любого распространённого
контейнера или видео, из которого нужна только речь. Подготовка на стороне
пользователя не требуется.
## Завершение
1. Перечень принимаемых форматов замерен и записан в `research/`, а не выведен
из документации ffmpeg.
2. Видеофайл принимается, и из него берётся звуковая дорожка.
3. Формат, который принять нельзя, отклоняется на приёме — с текстом, из
которого понятно почему, а не отказом на конвертации через минуту.
4. Расхождение ogg/vorbis против заявленного SpeechKit `OGG_OPUS` разобрано:
либо устранено, либо записано как проверенно безвредное.
+2 -4
View File
@@ -3,11 +3,9 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Второй способ представиться ставится на готовые владельца и контракт, иначе форма ошибки переписывается дважды. - **Категория:** Очередь — Второй способ представиться ставится на готовые владельца и контракт, иначе форма ошибки переписывается дважды.
- **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем. - **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
- **Теги:** goal:multi-user
Двигает пункты 1 и 6 «Завершения» цели: запрос без токена не проходит (пункт 1), Запрос без токена не проходит, а скрипт ходит в API по токену, выпущенному
а скрипт ходит в API по токену, выпущенному пользователем, и видит ровно его пользователем, и видит ровно его записи.
записи (пункт 6).
Токен принадлежит учётной записи и даёт ровно её права: записи, заведённые по Токен принадлежит учётной записи и даёт ровно её права: записи, заведённые по
токену, видны владельцу в приложении, и наоборот. токену, видны владельцу в приложении, и наоборот.
+5 -7
View File
@@ -3,11 +3,9 @@
- **Тип:** research - **Тип:** research
- **Категория:** Очередь — Форматы: сначала замер того, что конвейер берёт на самом деле. - **Категория:** Очередь — Форматы: сначала замер того, что конвейер берёт на самом деле.
- **Зачем:** Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона. - **Зачем:** Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
- **Теги:** goal:any-audio-source
Двигает пункты 1 и 4 «Завершения» цели: перечень принимаемых форматов замерен и Замер даёт перечень принимаемых форматов и разбирает расхождение `ogg/vorbis`
записан, а расхождение `ogg/vorbis` против заявленного SpeechKit `OGG_OPUS` против заявленного SpeechKit `OGG_OPUS`.
разобрано.
## Вопрос ## Вопрос
@@ -19,9 +17,9 @@
- `docs/research/audio-formats.md` — таблица «формат на входе → исход», с - `docs/research/audio-formats.md` — таблица «формат на входе → исход», с
командой замера и версией ffmpeg, на которой он сделан; командой замера и версией ffmpeg, на которой он сделан;
- расхождение `ogg/vorbis` против `OGG_OPUS`: строка о том, устранено оно или - расхождение `ogg/vorbis` против `OGG_OPUS`: строка о том, устранено оно или
проверенно безвредно, и чем это подтверждено; проверено безвредно, и чем это подтверждено;
- форматы, которые принять нельзя, — задачей об отказе на приёме, с провенансом - форматы, которые принять нельзя, — задачей об отказе на приёме, и она
этой разведки. называет эту разведку.
## Рамки ## Рамки
@@ -1,7 +1,7 @@
# 🧹 Запретить обращаться к Bot API мимо клиента бота # 🧹 Запретить обращаться к Bot API мимо клиента бота
- **Тип:** chore - **Тип:** chore
- **Категория:** Очередь — Класс уже дал утечку токена; сегодня его держат две проверки на сегодняшних местах, а не правило. - **Категория:** Очередь — Правило границы клиента ставится на тот же транспорт, мелочи которого разбирает строка выше: своя обёртка, заведённая раньше правила, вернёт утечку токена молча.
- **Зачем:** Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта. - **Зачем:** Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
Токен бота стоит в пути каждого обращения к Bot API, а `http.Client` кладёт Токен бота стоит в пути каждого обращения к Bot API, а `http.Client` кладёт
-45
View File
@@ -1,45 +0,0 @@
# 🧹 Переименовать образец конфига в config.example.toml
- **Тип:** chore
- **Категория:** Очередь — Поднято наверх: шесть задач ниже правят конфиг и каждая допишет старое имя образца, удлиняя перечень мест переименования.
- **Зачем:** Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
Конвенция конфигурации взята из проекта jellybit и **сама называет сегодняшнее
имя расхождением**: `docs/conventions/config.md`, строка 7 — «образец называется
`config.dist.toml`, а не `config.example.toml`». Но строки 31 и 34 того же
документа пишут `config.dist.toml` как правило, с заголовком раздела и всем
прочим. Документ противоречит сам себе, и который из двух читать — не выводится.
Задача закрывает расхождение в пользу конвенции: файл переименовывается, а
документ перестаёт спорить сам с собой.
## Затрагивает
- `config.dist.toml` в корне — переименование;
- `docs/conventions/config.md` — строка расхождения, заголовок раздела и все
упоминания имени;
- `CLAUDE.md`, раздел «Команды» — строка про то, что копировать;
- `README.md` — команда `cp` и абзац про недостающий ключ;
- `docs/review.md`, «Триггеры метки» — упоминание образца;
- `docs/conventions/README.md` — строка про самодокументируемый образец;
- `tasks/items/external-call-timeouts.md`, `telegram-account-link.md`,
`oidc-login.md` — разделы «Затрагивает» ссылаются на имя.
## Критерии приёмки
- Имени `config.dist.toml` в репозитории не осталось. Оракул —
`grep -rn 'config\.dist\.toml' . --exclude-dir=.git` пуст.
- Образец лежит под именем `config.example.toml` и по-прежнему не даёт
закоммитить реальный конфиг. Оракул — `git ls-files config.example.toml`
отдаёт файл, `git check-ignore config.toml` отдаёт `config.toml`.
- Конвенция больше не называет имя образца расхождением. Оракул —
`grep -n 'config\.example\.toml' docs/conventions/config.md` не находит строки,
противопоставляющей одно имя другому (сегодня это строка 7).
- Гейт зелёный: битых ссылок правка не оставила. Оракул — `task docs`.
## Рамки
Состав полей образца и его комментарии не пересматриваются — задача про имя и
про ссылки на него. Прорехи образца, помеченные в конвенции строками
«*Расхождение:*», остаются на месте.
-27
View File
@@ -1,27 +0,0 @@
# 🎯 Человек убирает свою запись из архива вместе со всеми текстами
- **Тип:** goal
- **Секция:** Запланировано — Очередь у цели появилась: удаление записи стоит 32-й строкой и трогает конвейер, файлы и колонку дедупликации, которые к тому месту готовы.
- **Зачем:** Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
- **Теги:** decomposed
Сервис объявлен архивом 2026-08-11, и с тем же решением у человека появляется
обратное право: сказать «убери это» и убедиться, что убрано. Речь в записи
принадлежит тем, кто говорил, а не хранилищу.
Стирается всё, что породила запись: сам файл, его фрагменты, объект в Object
Storage и все уровни текста. **Учёт расхода при этом остаётся** — деньги уже
потрачены, и сводка владельца задним числом не переписывается; строки
потребления несут идентификаторы и числа, не текст.
## Завершение
1. Своя запись убирается одним действием, и после него не остаётся ни файла, ни
объекта в хранилище, ни одного из уровней текста.
2. Убранное не возвращается: восстановления нет, и человек предупреждён об этом
до подтверждения.
3. Чужую запись убрать нельзя — ни по идентификатору, ни по токену.
4. Сводка расхода после удаления не меняется: потраченное остаётся видно
владельцу сервиса.
5. Тот же файл, загруженный снова, обрабатывается как новая запись, а не
узнаётся дедупликацией удалённой.
+1 -3
View File
@@ -3,10 +3,8 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Дедупликация ищет совпадение в пределах пользователя — то есть после владельца записи, и экономит деньги с первого дня приложения. - **Категория:** Очередь — Дедупликация ищет совпадение в пределах пользователя — то есть после владельца записи, и экономит деньги с первого дня приложения.
- **Зачем:** Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе. - **Зачем:** Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
- **Теги:** goal:upload-reliability
Двигает пункт 1 «Завершения» цели: повторная отправка того же файла возвращает Повторная отправка того же файла возвращает прежнюю запись вместо второй задачи.
прежнюю запись вместо второй задачи.
Совпадение ищется **в пределах одного пользователя**: чужая расшифровка по Совпадение ищется **в пределах одного пользователя**: чужая расшифровка по
совпадению хеш-суммы не отдаётся и о её существовании отправитель не узнаёт. совпадению хеш-суммы не отдаётся и о её существовании отправитель не узнаёт.
+2 -4
View File
@@ -3,11 +3,9 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Удаление трогает конвейер, файлы, объект хранилища и колонку дедупликации — всё это к этому месту уже готово. - **Категория:** Очередь — Удаление трогает конвейер, файлы, объект хранилища и колонку дедупликации — всё это к этому месту уже готово.
- **Зачем:** Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере. - **Зачем:** Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
- **Теги:** goal:data-ownership
Двигает все пять пунктов «Завершения» цели: запись убирается одним действием Запись убирается одним действием вместе с файлом, объектом в хранилище и всеми
вместе с файлом, объектом в хранилище и всеми уровнями текста. Чужую запись уровнями текста. Чужую запись убрать нельзя. Учёт расхода остаётся.
убрать нельзя. Учёт расхода остаётся.
Удаление необратимо и потому спрашивает подтверждения. Задача, которая ещё в Удаление необратимо и потому спрашивает подтверждения. Задача, которая ещё в
работе, тоже убирается: конвейер обязан заметить исчезнувшую запись и не работе, тоже убирается: конвейер обязан заметить исчезнувшую запись и не
+11 -11
View File
@@ -1,14 +1,19 @@
# 🧹 Свести шесть расхождений между документами канона # 🧹 Свести пять расхождений между документами канона
- **Тип:** chore - **Тип:** chore
- **Категория:** Очередь — Находки одной сверки: чинится одним заходом, пока помнится, чем каждое место было найдено. - **Категория:** Очередь — Документы правятся до того, как на них обопрутся экраны и контракт: расхождение в таблице зависимостей и в периметре читают, принимая решения ниже по списку.
- **Зачем:** Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; четыре из них в architecture.md, и по ним читатель строит решения о выкладке и о периметре. - **Зачем:** Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; одно сведено при повышении раскладки, а три из пяти оставшихся стоят в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
Находки сверки документов агентами `doc-consistency` и `doc-code-drift`, Находки сверки документов агентами `doc-consistency` и `doc-code-drift`,
прогнанной 2026-08-13 вместе с работой о контексте и токене. К той работе прогнанной 2026-08-13 вместе с работой о контексте и токене. К той работе
расхождения отношения не имеют — они старше, и потому не чинились тем же расхождения отношения не имеют — они старше, и потому не чинились тем же
коммитом. коммитом.
Мест было шесть. Шестое — вид временной метки, где `conventions/database.md`
требовал RFC 3339 с `T`, а хранилище пишет `2006-01-02 15:04:05.000Z`, — сведено
2026-08-13 строкой «*Расхождение:*» в конвенции при повышении раскладки до
версии 3. Остальные пять живы.
Каждое место названо с домом факта, то есть с тем документом, который прав: Каждое место названо с домом факта, то есть с тем документом, который прав:
1. **Панель администратора против Authelia.** `architecture.md`, «Открытые 1. **Панель администратора против Authelia.** `architecture.md`, «Открытые
@@ -26,11 +31,7 @@
4. **gin в `README.md`.** Веб-фреймворка нет: HTTP-поверхность — роутер 4. **gin в `README.md`.** Веб-фреймворка нет: HTTP-поверхность — роутер
встроенной PocketBase, и `logging.md` прямо говорит, что вместе с gin ушёл и встроенной PocketBase, и `logging.md` прямо говорит, что вместе с gin ушёл и
`sloggin`. Дом стека — `CLAUDE.md`. `sloggin`. Дом стека — `CLAUDE.md`.
5. **Вид временной метки.** `conventions/database.md`: RFC 3339 с `T`, секундная 5. **Дубли текста в `CLAUDE.md`** — подавления `hadolint` и настройка
точность. `docs/database.md`: `2006-01-02 15:04:05.000Z`, и вид обязателен
побайтово — сравнение в SQLite строковое. Дом — `docs/database.md`;
конвенции нужна строка «*Расхождение:*».
6. **Дубли текста в `CLAUDE.md`** — подавления `hadolint` и настройка
`errcheck` пересказаны там дословно, хотя обе преамбулы договорились, что `errcheck` пересказаны там дословно, хотя обе преамбулы договорились, что
дом перечня подавлений — `go-linters.md`. дом перечня подавлений — `go-linters.md`.
@@ -38,7 +39,6 @@
- `docs/architecture.md` — «Открытые вопросы», таблица внешних зависимостей, - `docs/architecture.md` — «Открытые вопросы», таблица внешних зависимостей,
раздел «Эксплуатация»; раздел «Эксплуатация»;
- `docs/conventions/database.md` — вид временной метки;
- `docs/security.md` и `docs/database.md` — как дома фактов, если правка - `docs/security.md` и `docs/database.md` — как дома фактов, если правка
потребует уточнить формулировку; потребует уточнить формулировку;
- `README.md` — перечень технологий; - `README.md` — перечень технологий;
@@ -46,8 +46,8 @@
## Критерии приёмки ## Критерии приёмки
- Ни одно из шести мест не отвечает на свой вопрос двумя способами. Оракул — - Ни одно из пяти мест не отвечает на свой вопрос двумя способами. Оракул —
повторный прогон `av-dev-docs:healthcheck`: перечисленные шесть находок не повторный прогон `av-dev:doc-healthcheck`: перечисленные пять находок не
возвращаются. возвращаются.
- Провайдер OIDC стоит в таблице внешних зависимостей со своими четырьмя - Провайдер OIDC стоит в таблице внешних зависимостей со своими четырьмя
столбцами отказа, и счёт зависимостей в «Открытых вопросах» сходится с столбцами отказа, и счёт зависимостей в «Открытых вопросах» сходится с
+2 -3
View File
@@ -3,10 +3,9 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Второй канал на той же доставке. - **Категория:** Очередь — Второй канал на той же доставке.
- **Зачем:** Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет. - **Зачем:** Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
- **Теги:** goal:ready-notification
Двигает пункты 1, 2 и 3 «Завершения» цели: готовый текст и отказ доходят Готовый текст и отказ доходят письмом, а адрес берётся у учётной записи, а не из
письмом, а адрес берётся у учётной записи, а не из общего конфига. общего конфига.
Почта — второй канал рядом с тем, что заводит `ntfy-delivery`; выбор канала Почта — второй канал рядом с тем, что заводит `ntfy-delivery`; выбор канала
остаётся в той же единой точке, что и сейчас. остаётся в той же единой точке, что и сейчас.
+1 -1
View File
@@ -25,7 +25,7 @@
аргументом; аргументом;
- `internal/controller/worker/worker.go` и `internal/service/transcribe.go` - `internal/controller/worker/worker.go` и `internal/service/transcribe.go`
протаскивание контекста в шаг; протаскивание контекста в шаг;
- `config.dist.toml` и `internal/config` — числа таймаутов; - `config.example.toml` и `internal/config` — числа таймаутов;
- `docs/database.md`, таблица настроек с числовым значением. - `docs/database.md`, таблица настроек с числовым значением.
## Критерии приёмки ## Критерии приёмки
+2 -3
View File
@@ -3,10 +3,9 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Метрики внешних сервисов пишутся в выбранном словаре, а не переписываются потом. - **Категория:** Очередь — Метрики внешних сервисов пишутся в выбранном словаре, а не переписываются потом.
- **Зачем:** Ни у Telegram, ни у Object Storage, ни у SpeechKit нет ни одной метрики: отказ внешнего сервиса виден только строкой в журнале контейнера. - **Зачем:** Ни у Telegram, ни у Object Storage, ни у SpeechKit нет ни одной метрики: отказ внешнего сервиса виден только строкой в журнале контейнера.
- **Теги:** goal:service-observability
Двигает пункты 2 и 5 «Завершения» цели: у каждого внешнего сервиса появляются У каждого внешнего сервиса появляются вызовы, отказы и длительность, а расход на
вызовы, отказы и длительность, а расход на платные сервисы виден числом. платные сервисы становится виден числом.
Внешних сервисов сегодня четыре — Telegram, Object Storage, SpeechKit и Внешних сервисов сегодня четыре — Telegram, Object Storage, SpeechKit и
`ffmpeg`/`ffprobe` как внешний процесс; пятым станет языковая модель. Метрика `ffmpeg`/`ffprobe` как внешний процесс; пятым станет языковая модель. Метрика
@@ -1,43 +0,0 @@
# 🧹 Ронять гейт на изменённой функции, которую не выполняет ни один тест
- **Тип:** chore
- **Категория:** Очередь — Непокрытую изменённую функцию дважды ловил проход ревью, а не машина; порог решён 2026-08-12, брать можно.
- **Зачем:** Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
`CLAUDE.md`, раздел «Гейт», объявляет прямо: «покрытие изменённых строк не
считается ничем». Цена этого измерена дважды. В задаче
`http-handler-tests-never-green` тесты обработчика не были зелёными ни разу; в
`pocketbase-storage` два из трёх шагов конвейера переписали целиком и не
выполнили ни одним тестом — нашёл это проход ревью, а не машина.
**Единица счёта — функция, а не строка** (решение владельца 2026-08-12). Шаг
краснеет на новой или изменённой функции, которую не выполняет ни один тест;
доля покрытых строк внутри неё не считается и порогом не ограничивается. Довод:
процент изменённых строк роняет гейт на всякой ветке отказа, которую нечем
изобразить в тесте, а порог ниже ста пришлось бы брать из ниоткуда. Именно
непокрытая целиком функция — то, что дважды ловили руками.
## Затрагивает
- набор шагов `task gate` в `Taskfile.yml` и переменная `BASE` как база диффа;
- семантика гейта в `CLAUDE.md`, раздел «Гейт», строка про покрытие;
- `docs/review.md`, раздел настройки конвейера: чем проход `autotests` перестаёт
заниматься руками.
## Критерии приёмки
- Изменённая строка без покрытия роняет гейт. Оракул — прогон на дереве, где в
тронутый файл добавлена заведомо невыполняемая ветка: шаг краснеет с её
адресом.
- Изменение, не трогающее код, шаг не гоняет. Оракул — `task gate` на дереве с
правкой одной только документации: шаг сообщает о пропуске с причиной.
- Единица счёта названа в `CLAUDE.md` и совпадает с тем, что проверяет шаг.
Оракул — `task gate`, шаг `docs.py check`.
- Функция, тронутая правкой на одну строку, шаг не роняет, если её вызывает хоть
один тест. Оракул — прогон на дереве с однострочной правкой внутри покрытой
функции: шаг зелёный.
## Рамки
Общее покрытие проекта не считаем и порога на него не ставим: он растёт от
тестов на тривиальное и не отвечает ни на один вопрос.
-34
View File
@@ -1,34 +0,0 @@
# 🔬 Шаги гейта, у которых правило может потерять предмет
- **Тип:** research
- **Категория:** Очередь — Разведка о чужих скриптах: пока ответа нет, неизвестно даже, есть ли работа.
- **Зачем:** У шага migrations страж предмета есть, у шагов docs, tasks и openspec неизвестно: они зовут чужие скрипты из плагинов, и правило, потерявшее файлы, зеленело бы молча.
Класс известен и записан: правило, чей предмет исчез, обходит пустой перечень
ноль раз и проходит зелёным. В `internal/archrules` от этого стоит
`TestПакетыПравилСуществуют` — он падает, когда пакет из правила переименован. У
шага `migrations` страж завёлся 2026-08-13: пустой каталог шагов роняет шаг с
кодом 3.
Чего не знаем: ведут ли себя так же `docs.py check`, `tasks.py check` и
`openspec.py check`. Скрипты чужие — они живут в плагинах `av-dev-docs`,
`av-dev-tasks` и `av-dev-code`, и править их в этом репозитории нельзя. Отсюда и
тип записи: способ починки зависит от ответа. Найдётся страж внутри — делать
нечего; не найдётся — либо обёртка в `Taskfile.yml` со своей проверкой предмета,
либо разговор с владельцем плагина.
## Вопрос
Какие шаги гейта проходят зелёными, когда предмет их правила исчез, — и чем это
чинится, если сам скрипт править нельзя?
## Куда ляжет ответ
`docs/research/gate-steps-subject-guard.md` — записка с перечнем шагов, снятыми
исходами (по каждому: что сделали с предметом, каким кодом ответил шаг) и
рекомендацией. Исход разведки — задачи на те шаги, где страж нужен и возможен.
## Рамки
Скрипты плагинов не правим: они не в этом репозитории. Прогоны идут на временном
клоне репозитория, каталоги `docs/` и `tasks/` рабочего дерева не трогаем.
+5 -6
View File
@@ -1,14 +1,13 @@
# ✨ Показывать заголовок в списке, отбирать список по темам и считать токены # ✨ Показывать заголовок в списке, отбирать список по темам и считать токены
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Три пункта «Завершения» цели не закрывала ни одна задача: показывать и отбирать можно, когда заголовки и темы уже считаются. - **Категория:** Очередь — Показывать и отбирать можно, когда заголовки и темы уже считаются.
- **Зачем:** Заголовок, темы и пересказ считаются, но список по-прежнему показывает первые слова расшифровки и не отбирается ничем, а расход на модель не виден числом. - **Зачем:** Заголовок, темы и пересказ считаются, но список по-прежнему показывает первые слова расшифровки и не отбирается ничем, а расход на модель не виден числом.
- **Теги:** goal:text-insights
Двигает пункты 1, 4 и 6 «Завершения» цели — те три, где выводы из текста Выводы из текста становятся видны человеку и владельцу: заголовок в списке,
становятся видны человеку и владельцу: заголовок в списке (1), отбор по темам отбор по темам, стоимость числом. Сами уровни считает `llm-insights-adapter`,
(4), стоимость числом (6). Сами уровни считает `llm-insights-adapter`, показать показать их некому: экран списка написан раньше и знает только первые слова
их некому: экран списка написан раньше и знает только первые слова расшифровки. расшифровки.
Берётся после `llm-insights-adapter`: пока заголовков и тем нет, показывать и Берётся после `llm-insights-adapter`: пока заголовков и тем нет, показывать и
отбирать нечего. отбирать нечего.
+4 -6
View File
@@ -3,11 +3,9 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Ставить на телефон есть смысл, когда есть что ставить. - **Категория:** Очередь — Ставить на телефон есть смысл, когда есть что ставить.
- **Зачем:** Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет. - **Зачем:** Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
- **Теги:** goal:web-access
Двигает пункты 5 и 6 «Завершения» цели: приложение ставится с телефона и Приложение ставится с телефона и запускается с ярлыка без адресной строки, а
запускается с ярлыка без адресной строки, а открытое без сети показывает это открытое без сети показывает это состоянием.
состоянием.
Берётся после того, как есть что ставить, — то есть после Берётся после того, как есть что ставить, — то есть после
`records-list-screen`. `records-list-screen`.
@@ -41,5 +39,5 @@
## Рамки ## Рамки
Офлайн-чтения готовых расшифровок и очереди отправки без сети **не делаем** Офлайн-чтения готовых расшифровок и очереди отправки без сети **не делаем**
это за границей цели. Web Push не делаем: уведомления идут через apprise и ntfy, это за границей из паспорта. Web Push не делаем: уведомления идут через apprise
цель `ready-notification`. и ntfy — задача `ntfy-delivery`.
+3 -4
View File
@@ -3,11 +3,10 @@
- **Тип:** research - **Тип:** research
- **Категория:** Очередь — Второй замер — остальные четыре звена. - **Категория:** Очередь — Второй замер — остальные четыре звена.
- **Зачем:** Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно. - **Зачем:** Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
- **Теги:** goal:long-recordings
Пункт 1 «Завершения» цели требует замера пяти звеньев, а разведка Звеньев пять, а разведка `speechkit-limits` меряет одно — модель
`speechkit-limits` меряет одно — модель `deferred-general`. Остальные четыре `deferred-general`. Остальные четыре дешевле: они не требуют боевых ключей и
дешевле: они не требуют боевых ключей и считаются локально, кроме заливки. считаются локально, кроме заливки.
Числа нужны раньше кода: они назначают потолок, который проверяет приём, и длину Числа нужны раньше кода: они назначают потолок, который проверяет приём, и длину
фрагмента, на которые режет `long-audio-chunking`. фрагмента, на которые режет `long-audio-chunking`.
+2 -4
View File
@@ -1,12 +1,10 @@
# ✨ Собирать путь одной записи по конвейеру запросом # ✨ Собирать путь одной записи по конвейеру запросом
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Пункт 3 «Завершения» цели не закрывала ни одна задача; применяет словарь, который выберет разведка строкой выше. - **Категория:** Очередь — Применяет словарь метрик, который выберет разведка строкой выше.
- **Зачем:** Звенья пути связаны только идентификатором задачи в строках журнала: чтобы понять, где запись провела минуты, владелец читает логи контейнера глазами. - **Зачем:** Звенья пути связаны только идентификатором задачи в строках журнала: чтобы понять, где запись провела минуты, владелец читает логи контейнера глазами.
- **Теги:** goal:service-observability
Двигает пункт 3 «Завершения» цели: путь одной записи по конвейеру собирается Путь одной записи по конвейеру собирается запросом, а не чтением логов глазами.
запросом, а не чтением логов глазами.
Путь длиной в минуты идёт через четыре внешних сервиса и три воркера. Сегодня Путь длиной в минуты идёт через четыре внешних сервиса и три воркера. Сегодня
его звенья связывает `job_id` в строках журнала, и собирает их человек. его звенья связывает `job_id` в строках журнала, и собирает их человек.
+2 -3
View File
@@ -3,10 +3,9 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Единая точка трансляции доменной ошибки — база и для экранов, и для токенов; список своих записей заводится после владельца, а не до. - **Категория:** Очередь — Единая точка трансляции доменной ошибки — база и для экранов, и для токенов; список своих записей заводится после владельца, а не до.
- **Зачем:** Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем. - **Зачем:** Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
- **Теги:** goal:web-access
Двигает пункты 1, 2 и 4 «Завершения» цели: экраны заводят задачу, видят её Экраны заводят задачу, видят её состояние и листают список — всё через один
состояние и листают список — всё через один контракт. контракт.
Обработчик `GET /api/status/:id` сегодня отвечает `404` на **любую** ошибку Обработчик `GET /api/status/:id` сегодня отвечает `404` на **любую** ошибку
чтения, включая сбой базы, а `POST /api/audio``500` на любую ошибку заведения, чтения, включая сбой базы, а `POST /api/audio``500` на любую ошибку заведения,
+2 -3
View File
@@ -3,10 +3,9 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Вычитанный текст считается тем же адаптером. - **Категория:** Очередь — Вычитанный текст считается тем же адаптером.
- **Зачем:** Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет. - **Зачем:** Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
- **Теги:** goal:text-insights
Двигает пункт «Завершения» цели про литературный текст: у записи появляется У записи появляется второй уровень текста — тот же разговор, вычитанный до
второй уровень — тот же разговор, вычитанный до читаемого вида. читаемого вида.
Вычитку считает та же внешняя модель, что заголовок и темы. Сырой текст Вычитку считает та же внешняя модель, что заголовок и темы. Сырой текст
остаётся и не переписывается: уровни лежат рядом, а не поверх друг друга. остаётся и не переписывается: уровни лежат рядом, а не поверх друг друга.
+2 -4
View File
@@ -3,11 +3,9 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Уровни текста: сюда приходит пятая внешняя зависимость, и конвейер к этому моменту покрыт тестами. - **Категория:** Очередь — Уровни текста: сюда приходит пятая внешняя зависимость, и конвейер к этому моменту покрыт тестами.
- **Зачем:** Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет. - **Зачем:** Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
- **Теги:** goal:text-insights
Двигает пункты 1, 3, 4 и 5 «Завершения» цели: у готовой записи появляются У готовой записи появляются заголовок, пересказ и темы, а отказ и молчание
заголовок (1), пересказ (3) и темы (4), а отказ и молчание модели не роняют модели не роняют задачу.
задачу (5).
Здесь появляется пятая внешняя зависимость — языковая модель с Здесь появляется пятая внешняя зависимость — языковая модель с
OpenAI-совместимым интерфейсом за шлюзом bifrost, — и текст расшифровки уходит OpenAI-совместимым интерфейсом за шлюзом bifrost, — и текст расшифровки уходит
@@ -1,35 +0,0 @@
# 🧹 Поднимать сервис локально без действующего токена бота
- **Тип:** chore
- **Категория:** Очередь — Поднято к долгам входа: живой прогон нужен именно им, а сегодня его нет ни у одной задачи.
- **Зачем:** Адаптер Telegram проверяет токен обращением к Telegram и роняет старт, а боевым токеном запускаться запрещено: проверить поведение живым прогоном не может ни одна задача.
Замечено при попытке проверить вход вживую в задаче `oidc-login` 2026-08-12;
подтверждено прогоном: с выдуманным токеном старт кончается отказом создания
отправителя раньше, чем поднимается HTTP-сервер.
Отсюда следствие, которое стоит дороже самого неудобства: **поведенческая
верификация живым запуском недоступна проекту вовсе**. Всякая задача, меняющая
наблюдаемое поведение, проверяется только тестами, а «поднять и посмотреть»
остаётся человеку с боевым конфигом.
Запрет запускаться боевым токеном снимать не надо: второй процесс с тем же
токеном перехватывает обновления у работающего.
## Затрагивает
- создание отправителя Telegram при старте в `main.go`;
- секция `[telegram]` конфига и её образец;
- раздел «Запреты» в `CLAUDE.md` — строка про боевой токен остаётся, но рядом
появляется способ поднять сервис без него;
- `docs/review.md`, подраздел «Недоступно проверке»: строка про недоступность
живого прогона снимается или сужается.
## Критерии приёмки
- Сервис поднимается с пустым токеном бота: HTTP отвечает, воркеры идут, бот не
создан. Оракул — запуск с конфигом без токена и запрос `GET /health`: код 200.
- Отсутствие бота названо в журнале один раз при старте, а не молчанием. Оракул —
тот же запуск: в выводе есть строка о том, что бот не поднят и почему.
- Поведение с настоящим токеном не изменилось. Оракул — тест на создание
отправителя с непустым токеном: прежний путь сохранён.
+2 -2
View File
@@ -4,7 +4,7 @@
- **Категория:** Очередь — Разведка закрывает тему входа последней: остальные три задачи меняют то, что она проверяет. - **Категория:** Очередь — Разведка закрывает тему входа последней: остальные три задачи меняют то, что она проверяет.
- **Зачем:** Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было. - **Зачем:** Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было.
Провенанс — отчёт триажа ревью задачи `oidc-login` 2026-08-12, Откуда — отчёт триажа ревью задачи `oidc-login` 2026-08-12,
[review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md), [review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
раздел «Гипотезы без доказательства». Каждая либо становится задачей, либо раздел «Гипотезы без доказательства». Каждая либо становится задачей, либо
закрывается с причиной; сегодня они не то и не другое. закрывается с причиной; сегодня они не то и не другое.
@@ -30,7 +30,7 @@
## Куда ляжет ответ ## Куда ляжет ответ
- подтверждённый путь — задачей в беклоге, с провенансом этой разведки; - подтверждённый путь — задачей в беклоге, и она называет эту разведку;
- опровергнутый — строкой в `docs/security.md`, раздел «Что вне модели» либо - опровергнутый — строкой в `docs/security.md`, раздел «Что вне модели» либо
«Что разграничивает доступ», чтобы следующее ревью не открывало его заново; «Что разграничивает доступ», чтобы следующее ревью не открывало его заново;
- то, что зависит от настройки Authelia, — строкой там же, с указанием, какая - то, что зависит от настройки Authelia, — строкой там же, с указанием, какая
@@ -1,6 +1,6 @@
# 🧹 Строить адрес входа из настроек коллекции, а не из конфига # Строить адрес входа из настроек коллекции, а не из конфига
- **Тип:** chore - **Тип:** feature
- **Категория:** Очередь — Замыкает тройку правок обработчиков входа. - **Категория:** Очередь — Замыкает тройку правок обработчиков входа.
- **Зачем:** Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину. - **Зачем:** Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
+3 -4
View File
@@ -3,11 +3,10 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Резка на фрагменты — самая глубокая переделка конвейера, и она идёт по замеренным числам. - **Категория:** Очередь — Резка на фрагменты — самая глубокая переделка конвейера, и она идёт по замеренным числам.
- **Зачем:** Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз. - **Зачем:** Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
- **Теги:** goal:long-recordings
Двигает пункты 3, 5 и 6 «Завершения» цели: запись в пределах потолка доходит до Запись в пределах потолка доходит до текста целиком, долгая задача не занимает
текста целиком, долгая задача не занимает воркер на часы, а перезапуск на воркер на часы, а перезапуск на середине продолжает работу с первого
середине продолжает работу с первого неотмеченного фрагмента. неотмеченного фрагмента.
Запись делится на фрагменты, каждый распознаётся отдельно, готовый фрагмент Запись делится на фрагменты, каждый распознаётся отдельно, готовый фрагмент
отмечается в базе. После перезапуска работа продолжается с первого неотмеченного, а отмечается в базе. После перезапуска работа продолжается с первого неотмеченного, а
-28
View File
@@ -1,28 +0,0 @@
# 🎯 Запись длиной до шести часов доходит до текста
- **Тип:** goal
- **Секция:** Направления — Цель начинается с двух замеров и кончается резкой на фрагменты — самой глубокой переделкой конвейера: очереди внутри нет, пока числа не получены.
- **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
- **Теги:** decomposed
Лекция, созвон, интервью и диктофонная запись из семейного архива целиком
превращаются в текст. Сегодня неизвестно даже, на каком звене такая запись
отваливается, — цель начинается с замера, а не с переделки.
Расчётный потолок — **шесть часов**: он взят с запасом под диктофонные записи и
дорожки из видео, и замер проверяет, каким звеном он ограничен на самом деле.
## Завершение
1. Потолки каждого звена замерены и записаны в `research/` с командой замера:
приём из Telegram, приём по HTTP, конвертация, заливка в Object Storage,
модель `deferred-general`.
2. Запись, превышающая потолок, отклоняется на приёме понятным текстом, а не
висит в конвейере до истечения захвата.
3. Запись в пределах потолка доходит до текста и не теряет его хвост.
4. Текст в несколько сотен килобайт доходит до получателя: и в браузере, и в
Telegram, где предел сообщения — 4000 символов.
5. Долгая задача не блокирует короткие: запись на три часа не останавливает
конвейер для голосового на десять секунд.
6. Перезапуск сервиса на середине долгой расшифровки не начинает её заново:
работа продолжается с места остановки.
+1 -3
View File
@@ -3,10 +3,8 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Сотни килобайт текста появляются только после долгих записей. - **Категория:** Очередь — Сотни килобайт текста появляются только после долгих записей.
- **Зачем:** Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд. - **Зачем:** Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд.
- **Теги:** goal:long-recordings
Двигает пункт 4 «Завершения» цели: текст в несколько сотен килобайт доходит и в Текст в несколько сотен килобайт доходит и в Telegram, и в браузере.
Telegram, и в браузере.
Деление по словам (`internal/adapter/telegram/split.go`) остаётся для обычной Деление по словам (`internal/adapter/telegram/split.go`) остаётся для обычной
расшифровки; сверх названного числа частей вместо потока сообщений уходит один расшифровки; сверх названного числа частей вместо потока сообщений уходит один
@@ -1,48 +0,0 @@
# 🧹 Проверить шаг гейта migrations так же, как шаг сверки версий Go
- **Тип:** chore
- **Категория:** Очередь — Шаг уже стоит в гейте и уже назван стражем critical-инварианта в двух документах — необеспеченное обещание дороже отсутствующего.
- **Зачем:** Шаг охраняет critical-инвариант «применённый шаг схемы не переписывается», но своих проверок не имеет: дрейф шаблона имени, переезд каталога или потеря grep в конвейере оставят его вечно зелёным, и это не заметит ничто.
Шаг заведён 2026-08-13 и проверен мутацией на восьми исходах вручную — правка
уехавшего шага в дереве и в коммите, удаление, переименование, новый шаг, правка
`migrations.go`, отсутствующий ключ в `docs/.docs.json`, каталог без шагов,
неразрешимая база диффа. Прогон был разовым: в дереве от него не осталось ничего.
Прецедент рядом. У шага сверки версий Go есть спека
[toolchain](../../openspec/specs/toolchain/spec.md) и 20 мутационно проверенных
сценариев в `scripts/check_go_version_test.go`; заведены они после дефекта
2026-08-12, когда зелёный шаг не проверял ничего и образ перестал собираться.
Долг назван строкой в
[go-linters.md](../../docs/conventions/go-linters.md), «Границы: где что живёт».
**Развилка, решаемая внутри задачи:** нормировать шаг спекой (второй capability
о проверке, как `toolchain`) либо ограничиться проверками без нормы. Первое
дороже и даёт построчную сверку сценариев; второе закрывает регрессию, но
оставляет норму в комментарии `Taskfile.yml`.
## Затрагивает
- шаг `migrations` в `Taskfile.yml` — его логика разбора `git diff`;
- ключ `migrations` в `docs/.docs.json` — из него шаг берёт каталог;
- каталог шагов схемы `internal/adapter/repo/pocketbase/migrations/` как предмет
правила;
- возможно — новая capability в `openspec/specs/` и файл проверок рядом с
`scripts/check_go_version_test.go`.
## Критерии приёмки
- Переписанный уехавший шаг схемы роняет проверку. Оракул — прогон сценария на
временном клоне репозитория: правка файла шага даёт код 1 и называет файл.
- Новый файл шага проверку не роняет, и правка `migrations.go` тоже: строка
`Register` нового шага прибавляется именно там. Оракул — те же два сценария.
- Каталог без единого файла шага и отсутствующий ключ в `docs/.docs.json` дают
код 3, а не тихий ноль. Оракул — два сценария на временном каталоге.
- Проверка сценариев идёт в гейте, а не руками. Оракул — `task gate` красный при
внесённом нарушении шаблона имени файла шага.
## Рамки
Боевой каталог данных и файлы шагов схемы не трогаем: сценарии гоняются на
временном клоне репозитория. Чужие скрипты проверок (`docs.py`, `tasks.py`,
`openspec.py`) — не наши, они в задаче `gate-steps-subject-guard`.
+1 -3
View File
@@ -3,10 +3,8 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Пачка файлов заводится на готовом экране загрузки и готовой дедупликации. - **Категория:** Очередь — Пачка файлов заводится на готовом экране загрузки и готовой дедупликации.
- **Зачем:** Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки. - **Зачем:** Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
- **Теги:** goal:upload-reliability
Двигает пункт 2 «Завершения» цели: пачка файлов уходит одной загрузкой, и отказ Пачка файлов уходит одной загрузкой, и отказ одного не отменяет остальные.
одного не отменяет остальные.
## Затрагивает ## Затрагивает
-24
View File
@@ -1,24 +0,0 @@
# 🎯 Сервисом пользуются несколько человек, и записи одного не видны другому
- **Тип:** goal
- **Секция:** Запланировано — Владелец записи — фундамент, на котором стоят список своих записей, дедупликация, удаление, учёт расхода и квота: пока его нет, остальные цели строятся на песке.
- **Зачем:** У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
- **Теги:** decomposed
Приложение узнаёт, кто к нему пришёл, и показывает каждому только его записи.
Учётные записи заводит и проверяет внешний провайдер — Authelia по OIDC; своей
регистрации и своих паролей не делаем, это граница из
[паспорта](../../docs/passport.md).
## Завершение
1. Неаутентифицированный запрос к записям не проходит: ни к странице, ни к API.
2. У задачи и файла есть владелец, и выборка чужой записи по её
идентификатору возвращает «не найдено», а не содержимое.
3. Вход идёт через OIDC у Authelia; выход из сессии работает.
4. Пользователь Telegram сопоставлен с учётной записью, и записи, пришедшие
ботом, видны ему же в браузере.
5. Белый список Telegram перестаёт быть отдельным механизмом: право писать боту
выводится из учётной записи.
6. Скрипт ходит в API по токену, выпущенному пользователем, и видит ровно его
записи.
+8 -5
View File
@@ -3,12 +3,10 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь — Канал уведомлений выбирается в настройках, которые уже есть. - **Категория:** Очередь — Канал уведомлений выбирается в настройках, которые уже есть.
- **Зачем:** Пользователь веба узнаёт о готовности только опросом с открытого экрана. - **Зачем:** Пользователь веба узнаёт о готовности только опросом с открытого экрана.
- **Теги:** goal:ready-notification
Двигает пункты 1, 2, 4 и 5 «Завершения» цели: готовый текст и отказ доходят до Готовый текст и отказ доходят до пользователя веба без открытого приложения,
пользователя веба без открытого приложения, отказ канала задачу не роняет, а отказ канала задачу не роняет, а пользователь Telegram получает ответ
пользователь Telegram получает ответ по-прежнему ботом. Выбор канала самим по-прежнему ботом. Выбор канала самим пользователем заводит `settings-screen`.
пользователем (пункт 3) заводит `settings-screen`.
Сегодня `completeJob` и `failJob` отвечают только источнику `telegram`; Сегодня `completeJob` и `failJob` отвечают только источнику `telegram`;
источник `api` не получает ничего. Здесь появляется второй способ доставки, и источник `api` не получает ничего. Здесь появляется второй способ доставки, и
@@ -46,3 +44,8 @@
Web Push с VAPID и своим хранением подписок не делаем. Своего сервера ntfy не Web Push с VAPID и своим хранением подписок не делаем. Своего сервера ntfy не
поднимаем — адрес приходит конфигом. Текст расшифровки уходит на внешний сервис, поднимаем — адрес приходит конфигом. Текст расшифровки уходит на внешний сервис,
и это сдвиг периметра: строка в `docs/security.md` обязательна. и это сдвиг периметра: строка в `docs/security.md` обязательна.
Отказ от Web Push сегодня живёт открытым вопросом `docs/architecture.md`,
«Уведомления», и своего ADR не имеет: заводить его не из чего, пока нет
`design.md` этой задачи. Решение промоутится из него, когда задача пойдёт в
работу.
-1
View File
@@ -3,7 +3,6 @@
- **Тип:** research - **Тип:** research
- **Категория:** Очередь — Сопровождение: словарь метрик выбирается до того, как метрик станет втрое больше. - **Категория:** Очередь — Сопровождение: словарь метрик выбирается до того, как метрик станет втрое больше.
- **Зачем:** Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами. - **Зачем:** Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами.
- **Теги:** goal:service-observability
Эндпоинт `/metrics` остаётся и развивается — это решено. Вопрос в том, чем его Эндпоинт `/metrics` остаётся и развивается — это решено. Вопрос в том, чем его
развивать: дописывать счётчики в `internal/metrics` напрямую через развивать: дописывать счётчики в `internal/metrics` напрямую через

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