удалён вход Telegram, владелец записи стал обязателен в схеме

- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
av
2026-08-15 07:24:35 +03:00
parent 97ceb7bb69
commit 8f7c3a057a
74 changed files with 2453 additions and 2733 deletions
+91 -38
View File
@@ -51,14 +51,14 @@
- повтор шага на той же задаче не создаёт лишних файлов и записей;
- отвечает пользователю ровно один раз.
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
**Транспорт** (`internal/controller/http`):
- проверяет право отправителя до всякой работы;
- не логирует ошибку, которую уже залогировал доменный слой;
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
- закрывает то, что открыл, на всех ветках выхода.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
@@ -121,17 +121,19 @@
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост.
- **«Запись без владельца не достаётся никому».** Не дефект: владельца не имеют
записи, принятые ботом, — связи чата Telegram с учётной записью приложения
сервис не ведёт, её заводит `telegram-account-link`. Ответ такой записи по API
совпадает с ответом на несуществующую, и это норма — расшифровку отправитель
получает в чат.
- **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
иначе выбросил бы настоящую находку как известную.
**Прежняя редакция этой строки отменена 2026-08-14.** До задачи
`record-ownership` здесь стояло «вошедший видит чужие записи — не дефект и не
новость»: разграничения не было сознательно. Теперь оно есть, и такая находка
настоящая. Строка оставлена вместо удаления намеренно: прогон, помнящий её
прежний вид, выбросил бы регрессию не глядя.
До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
дефект и не новость»: разграничения не было сознательно. Первая отмена
2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
владельца, принятую ботом.
Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
колонка владельца пустого значения больше не принимает, ничьих записей у
сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
а не известное исключение.
### Вопросы по темам
@@ -145,10 +147,10 @@
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
дефектом «остановка хоронила запись»).
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
`s3.go`, `speechkit.go`, 2026-08-13).
- `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
`speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10).
@@ -161,14 +163,12 @@
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо
`createTranscribeJob` — сегодня через него идут оба входа
- `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
он единственный, которым запись попадает в хранилище
([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и
первые две описаны частично. Поведение прочих узлов, включая
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
ещё — самый большой.
заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
под маркерами долга, а соблазн дописать туда ещё — самый большой.
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
рубеж — одним дескриптором
(CLAUDE.md, «Инварианты»).
@@ -205,12 +205,12 @@
- замена хранилища или переход на PocketBase — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, трогающее оба входа сразу — Telegram и HTTP.
- изменение, убирающее или возвращающее вход приёма целиком.
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
пользователь приложения, до начала работы назвать нельзя;
- вход через OIDC и разграничение доступа: как связать чат Telegram с учётной
записью, до начала работы назвать нельзя;
- всё, что делается на выбранном фреймворке впервые: правила
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
на пробном экране, а не из написанного кода, и первая же задача проверяет их
@@ -224,7 +224,6 @@
**Мелкое здесь** (опускает до `small`):
- правка текста, который видит пользователь Telegram;
- новая метрика в `internal/metrics`;
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона.
@@ -259,19 +258,19 @@ API и имя не откатываются обратной правкой по
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом
(`openspec/specs/intake`, «Признак включения решает, поднимается ли вход
Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен
теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком»
снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона
сменился с пустого ключа доступа на выключенный вход задачей
`telegram-enabled-flag` того же дня.
можно: он встаёт своим единственным входом на выдуманных непустых ключах
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram,
расшифровку и заливку.
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
тоже недоступен: сессию в прогоне выдать нечем.
## Журнал дефектов
@@ -281,6 +280,60 @@ API и имя не откатываются обратной правкой по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
оракул, и выдумывать оракул задним числом нельзя.
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
и `StructureRepository.Put`; путь до них — `poll``storeOutcome` в
`internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не
касался: она этот путь не трогала
- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не
считается — наружу уходит пустой результат без отказа. Замена содержимого шла
безусловно, и повторный опрос той же операции клал пустое поверх сохранённой
расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос
готовности отдавал `done` без текста
- **Причина:** соседний хранитель того же результата — сырой ответ провайдера —
от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст
и структура реплик такого условия не имели. Разное правило у двух хранителей
одного результата
- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, —
`expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве:
`internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`;
он же проверяет, что до второго ответа дело действительно дошло
- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но
и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа
отказало либо человек снял остановку в панели. Ни один прогон до этого не строил
такого входа, а от чтения кода защита у соседа выглядела общей
- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано
нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих
текст, больше одного, и правило у одного из них у остальных читалось бы как
снятое. Дефект пред-существующий, чинился решением владельца от 2026-08-14 в
задаче, которая его нашла
## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]
- **Где:** `internal/service/transcribe.go`, шаг завершения; документы
`docs/conventions/logging.md` и `docs/architecture.md`
- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от
успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
- **Причина:** единственным следом этого случая был текст, уходивший отправителю
в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез
вместе с ней — при том, что конвенция журнала называет пустой текст
распознавания поимённым примером уровня «может стать проблемой», а обзор
архитектуры обещал заглушку
- **Чем воспроизведён:** `internal/service/recognition_test.go`,
`TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт
готовую операцию с пустым результатом, проверка судит уровень строки и
идентификатор записи
- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого
признака, а не сам признак; такое не видно ни компилятору, ни грепу по
удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем,
что осталось в коде
- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с
идентификатором записи; строка обзора архитектуры переписана на фактическое
поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным
потребителем сигнала** — сигнал переживает канал только там, где его переносят
руками
## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не