# Balda — Go/fx pitfalls и метод правок (зеркало скилла) > Создано 2026-09-26. Обновлено 2026-09-26 (сессия реализации MM-хендлера, полный проход; > 🔴 **статус приёмки ИСПРАВЛЕН** — см. §«Ложная приёмка» и §«Чинить в коде, а не в данных»). > Добавлены: **`bad_seq` — настоящая причина тишины WS** (§«WS-фрейм без seq»), > **`optional:"true"` скрывает отсутствующую зависимость** (§«Дефект 5»), > **`canonicalSubject` и неизвестный транспорт** (§«auth: subject-based»), > **`/start` — тупик онбординга при проверке доступа** (§«Дефект 6»), > **`base_url` в YAML мёртв — только `OPENAI_BASE_URL`** (§«Провайдер: …»), > **`FROM scratch` без CA** (§«scratch и TLS»), > **`principal` с префиксом в БД** (§«Дефект 7»), > **`state.db` читать вместе с WAL** (§«Метод: чтение sqlite-состояния»), > **🔴 ложная приёмка от чужого аккаунта** (§«Ложная приёмка»), > **🔴 двойной префикс жив в коде, а не только в БД** (§«Чинить в коде»), > **🔴 замкнутый круг привязки при занятом binding** (§«Дефект 8»). > 🔴 **Новое в этой сессии:** §«Mattermost Team Edition: что недоступно» — push невозможен. > Уточнён §«авторизация — ОДИН механизм»: челлендж НЕ был причиной №1, им был `bad_seq`. > Зеркало секции скилла `devops/docker-balda-service`, добавленной 2026-09-26. > Источник: работа над Mattermost-адаптером Балды (Фаза 2) + деплой на Kraken (Фаза 3). > Проектные доки: [[personal/projects/balda/balda-mattermost-connector]], > [[personal/projects/balda/balda-kraken-mm-deploy]], > [[personal/plans/balda-mm-endtoend-plan]], > [[personal/projects/balda/balda-mm-delegation-plan]]. --- ## 🔴🔴 Ложная приёмка: сквозной путь от ЧУЖОГО аккаунта — это не приёмка **Самый дорогой урок сессии.** Я отчитался «фаза закрыта, приёмка пройдена», проверив сквозной путь от `testuser` — аккаунта, созданного мной же для тестов. Alex написал `@balda привет` — тишина, в логе `settlement_stage="unauthorized"`. **Причина:** владельцем бота был привязан `testuser`. Аккаунта Alex в `balda_user_bindings` **не было вообще**. Путь работал — но только для тестового аккаунта. 📌 **Правило приёмки:** проверять **от имени заказчика**, его учёткой. Если система авторизует по `principal`/`user_id` — тест обязан идти с `user_id` заказчика. Сквозной путь, подтверждённый подставным аккаунтом, доказывает **только** работоспособность конвейера, но **не** работоспособность для пользователя. 🔴 **Красный флаг:** в тестовой ленте автором успешного запроса стоит НЕ тот `user_id`, который будет в проде. Это уже повод не объявлять приёмку. ⚠️ Отличать от НЕ-дефекта: пост **без** упоминания бота отбрасывается намеренно — `dropping posted event: bot not mentioned` (`MentionsBot`). В канале обращаться `@balda <текст>`; в DM упоминание не нужно. --- ## 🔴 Чинить в КОДЕ, а не в данных (двойной префикс вернулся) В прошлой сессии я нашёл, что `principal` в `balda_user_bindings` записан как `mattermost:c6udeze…` (с префиксом), и починил это **прямым `UPDATE` в БД**. Дефект **вернулся**: `display_name` остался `mattermost:mattermost:c6udeze…`, а `created_by` новых channel-токенов снова получил двойной префикс. **Настоящий источник — код**, `actors/command/start/handler.go:420`: ```go case transportMattermost: return auth.MattermostSubject(strings.TrimPrefix(trimmed, transportMattermost+":")) ``` `MattermostSubject()` (`auth/owner.go:307`) **сама** добавляет префикс вида `:`, а `p.Principal` приходит от MM-хендлера **уже с префиксом** (`mattermost_ingress.go:147`: `Principal: auth.MattermostSubject(cmd.SenderID)`). 📌 **Правило: дефект, найденный в данных, искать и в коде.** Правка данных без правки кода = дефект вернётся при следующей записи. Проверять **оба слоя** и фиксить источник. ### ✅ ИСПРАВЛЕНО 2026-09-26 (сессия PR-подготовки) — фикс в хелпере Вместо костыля `TrimPrefix` в вызывающем коде сделана **идемпотентной сама `MattermostSubject`**: ```go // MattermostSubject returns the channel-qualified subject for a Mattermost user. // It is idempotent: a value that is already channel-qualified ("mattermost:") // yields the same subject, so callers may pass either a raw user ID or a subject // without risking a doubled prefix ("mattermost:mattermost:"). func MattermostSubject(userID string) string { trimmed := strings.TrimSpace(userID) trimmed = strings.TrimPrefix(trimmed, ChannelMattermost+":") return fmt.Sprintf("%s:%s", ChannelMattermost, trimmed) } ``` `userSubject` вернулся к симметричному с другими транспортами виду: ```go case transportMattermost: return auth.MattermostSubject(trimmed) ``` 📌 **Почему фикс в хелпере, а не в вызывающем:** `MattermostSubject` вызывается из **трёх** мест (`userSubject`, `mattermost_ingress.go:147`, `authorizeInbound`). Идемпотентность закрывает все пути сразу и защищает будущие вызовы поверх готового subject'а. Дельта `tools/deltas-mattermost-23-prefix-idempotent.json`. **Общий приём для нового транспорта:** любой `Subject(id)`-билдер делать **идемпотентным** — срезать уже существующий `:`-префикс. Тогда вызывающий код может передавать и raw id, и готовый subject без риска двойного префикса. Это дешевле, чем искать все call-site'ы, и переживает добавление новых. **Регресс-тесты:** `TestMattermostSubjectIsIdempotent` (`handlersfx/mattermost_ingress_test.go`), `TestMattermostHandlerSendsChannelQualifiedPrincipalExactlyOnce`. ⚠️ Тот же класс ошибки — двойное применение helper'а, который сам добавляет префикс. Признак: строка вида `::` в БД, в `display_name`, в `created_by`, в логах. Искать надо `MattermostSubject`/`TelegramSubject`-подобные билдеры, вызванные поверх уже собранного subject'а. ⚠️ Остальные вызовы `MattermostSubject(cmd.SenderID)` в `mattermost_ingress.go` (строки 189, 277, 292) **корректны** — они получают чистый `user_id`. Дефект ровно один, в `userSubject` (`handler.go:420`). --- ## 🔴 Прогресс-политика должна совпадать на command- и turn-пути одного транспорта Найдено 2026-09-26 (сессия PR-подготовки). `handlersfx/mattermost_ingress.go` в `HandleCommand` ставил `ProgressPolicy{Typing: true, PlanUpdates: true}`, а inbound-путь (`channel/mattermost/inbound.go:135`) и `Adapter.SendTyping` — `Typing: false`. **Почему это дефект:** у ботов Mattermost **нет typing API**, `SendTyping` — осознанный no-op (`return nil`). При `Typing:true` `SendProgress` вызывает `SendTyping`, тот молча ничего не делает → мёртвая ветка. Она не падает, поэтому компилятор и линтер её не видят. 📌 **Правило:** command-путь и turn-путь **одного и того же** транспорта обязаны давать **идентичную** `ProgressPolicy`. Расхождение — невидимый мёртвый код. Проверять оба места: в `handlersfx/*_ingress.go` (публикация команды) и в `channel/*/inbound.go` (normalized turn). 📌 **Метод поиска:** сравнить `ProgressPolicy` в обоих файлах транспорта. Если транспорт не поддерживает typing — `Typing: false` **везде**. **Фикс:** дельта `tools/deltas-mattermost-24-typing.json`; тесты `TestMattermostHandlerUsesMarkdownWithoutTypingProgress`, `TestNormalizeInboundDisablesTypingProgress`. ## 🔴 Тесты нового транспорта: REST через `httptest`, WS через `Upgrader`, хендлер напрямую Метод, отработанный 2026-09-26 на MM (опубликован в PR — воспроизводить для любого нового транспорта): | Слой | Как тестировать | |---|---| | **REST-клиент/адаптер** | `httptest.NewServer` + fake-handler на `POST /api/v4/posts`, запоминающий запросы; инъецируемый `respond(attempt int, req) (status int, body string)` для проверки fallback (1-й → 400, 2-й → 201) | | **WebSocket-ингресс** | реальный `websocket.Upgrader` в `httptest` + `websocket.DefaultDialer` — **единственный** способ проверить, что `writeEvent` всегда шлёт `seq` | | **Хендлер** | тестировать **структуру напрямую** (`&xInboundHandler{...}`), без fx — изолирует авторизацию, префикс principal, nil-safety | | **Фикстуры** | `fake...OwnerKVStore` + `recording...CommandIngress` по образцу `zulip_ingress_test.go` | 📌 **Обязательные регресс-тесты для нового транспорта:** 1. `writeEvent` всегда шлёт `seq` (регрессия `bad_seq`). 2. `seq` уникален под конкуренцией (16 горутин) — иначе MM увидит переупорядоченный кадр. 3. `Subject` идемпотентен (регрессия двойного префикса). 4. `ProgressPolicy` совпадает на command- и turn-пути. 5. Fallback только на content-rejection (400), **не** на 5xx. 6. `/start` доступен до авторизации. 7. `Start` отказывается без обязательных конфигов (bot user id и т.п.). ### ⚠️ Метод: падающий тест — сначала перечитать контракт, а не «чинить» код Три первых теста MM падали, и **все три** оказались неверными предположениями теста, а не дефектами кода: | Я предположил | Реальность | |---|---| | тред-сессия == сессия root-поста | тред — **отдельная** сессия от канала (`mm-t-…` vs `mm-c-…`), это правильный дизайн | | `NewIngress` подставляет `bot_user_id` из клиента | не подставляет; гард — в `onStart` («refuses to start without bot user id») | | отказ неавторизованному возвращает `nil` | возвращает `runtime is unavailable` (нет dispatcher'а), но команда **не публикуется** — это и есть контракт | 📌 **Правило:** когда новый тест падает — сначала перечитать код и понять, **чей** это контракт. «Привести код к тесту» — неверный рефлекс: тест мог быть написан по неверному предположению. Из трёх падений ноль были дефектами. 📌 **Побочная находка (дизайн MM-локатора):** root-пост (`RootID=""`) → `mm-c-`, ответ в треде (`RootID=`) → `mm-t-`. **Тред — отдельная беседа от канала.** Соответствует комментарию в `locator_codec.go`, но означает: бот в треде ведёт сессию, отдельную от канала. Зафиксировано тестом. --- ## 🔴🔴 Дефект 8: замкнутый круг привязки, когда binding занят чужим аккаунтом Ситуация: владельцем привязан **не тот** аккаунт, а нужно привязать правильный. Все штатные пути оказываются закрыты. | Штатный путь | Почему не работает | |---|---| | `/start owner=` | `HasOwner()` = `len(OwnerSubjects()) != 0`. Владелец существует → код уходит в `handleExistingOwnerState` → `IsOwner(<нужный аккаунт>)` = false → **«Bot owner is already registered»** | | `/start ` для нужного канала | `CreateMissingOwnerBindTokens` (`auth/channel_token.go:215`) **пропускает** канал с `HasOwnerSubject(channel) == true`. Канал считается привязанным → **токен не создаётся вообще** | | `registerOwner` → `attachCanonicalBinding` | Кандидат ищется как `Role == administrator` **И `Binding == nil`** (`canonical_users.go:110`). Единственный админ уже имеет binding → `no unbound active administrator user` | 📌 **Механика, которую надо помнить:** - `HasOwner()` при `canonical != nil` — это `len(OwnerSubjects()) != 0`, а `OwnerSubjects()` собирается из **binding'ов** (не из роли). Снять binding = обнулить `HasOwner()`. - `AttachBinding` **не даёт перепривязать** (`count != 0` → `ErrBindingAlreadyAssigned`), **detach-API отсутствует**. - `purpose` channel-токена должен быть `owner_bind` — токен из другого назначения `ConsumeOwnerBind` отвергнет. 🔴 **Единственный выход без правки кода** — `UPDATE` строки binding: меняем `principal` (и `display_name`) на нужный аккаунт. Пользователь, роль и `HasOwner()` сохраняются, связь «владелец ↔ аккаунт» переезжает. Обратимо обратным `UPDATE`. ```sql UPDATE balda_user_bindings SET principal = '<нужный mm_user_id>', display_name = 'mattermost:<нужный mm_user_id>' WHERE channel_type = 'mattermost' AND principal = '<старый mm_user_id>'; ``` ⚠️ Перед `UPDATE` — бэкап `state.db` **вместе с `-wal`**; контейнер остановить (sqlite не любит конкурентной записи). Значения проверять через `sqlite3 … ".schema balda_app_kv"` — колонки `namespace, key, value_json, updated_at, expires_at` (`value`/`k` таких колонок НЕТ). 📌 **Диагностика «почему именно мне бот не отвечает»:** сравнить `user_id` из ленты канала с `principal` в `balda_user_bindings`. Несовпадение = `unauthorized`, и никакие логи транспорта этого не покажут. --- ## ⚠️ Приёмка, проверяемая агентом (без GUI-браузера) — но ОТ НУЖНОГО АККАУНТА Требование Alex: «приёмка = видно в UI, не "контейнер Up" и не "лог said accepted"». Агент без браузера **не может** посмотреть UI — но может верифицировать ответ бота однозначно, читая **ленту канала** REST'ом и сверяя `user_id` автора: ```bash ssh kraken 'curl -s -H "Authorization: Bearer *** /home/kraken/balda/secrets/mm-bot-token)" \ "http://localhost:8065/api/v4/channels/7bjw4g7o1jdsbmjbqd649xxgaw/posts?per_page=4" \ | jq -r ".order[] as \$o | .posts[\$o] | \"\(.user_id) | \(.message)\""' ``` Ожидаемый вид — пост человека и **пост с `user_id` = bot_user_id**: ``` c6udeze8dfrdxm5jbgq55bd58r | @balda reply with a single word edxrgupoxiyg5gzmr79tjypbme | работает ← это и есть доказательство ``` 📌 Это заменяет «видно в UI»: `user_id` автора поста — однозначный факт, не интерпретация. ⚠️ `post_user_id` в логах ингресса **не** подходит: там id отправителя запроса, не автора ответа. ⚠️ Не отчитываться «готово» по факту `delivery settled` в логах — доставляется ли пост, видно только из ленты. 🔴🔴 **Обязательное условие: `user_id` отправителя в тесте должен быть аккаунтом ЗАКАЗЧИКА.** Тест от подставного/тестового аккаунта доказывает работу конвейера, но не работу для пользователя — см. §«Ложная приёмка» выше. Этот самый промах и дал ложный доклад «фаза закрыта», после которого Alex написал «Нихуя он мне не отвечает в ММ. Не готово!!». --- ## 🔴 Mattermost Team Edition: что недоступно (проверено) `mattermost version` → `Build Enterprise Ready: false`. На TE **невозможно** включить push в официальных мобильных приложениях — это архитектура продукта, не настройка. | Что | Факт | |---|---| | **Тумблер push** | Живёт в `EmailSettings.SendPushNotifications`, **не** в `ServiceSettings` — `mmctl config get ServiceSettings.EnablePushNotifications` даёт `Error: invalid key` | | **Официальный push-прокси** | Жив (HTTP 200), но для TE-`DiagnosticId` отвечает `Did not send message because of missing platform property` — APNs/FCM-сертификаты только у лицензированных EE | | **Свой MPNS** | Требует **собственные подписанные сборки** мобильных приложений + Apple Developer ($99/год) + FCM. Официальные клиенты прошиты на bundle ID Mattermost Inc. и к своему MPNS не подключатся | | **SMTP-почта** | Единственный путь без лицензии. `SendEmailNotifications: true` уже стоит, но SMTP не настроен (`localhost:10025`, на хосте ни одного слушателя) | 🔴 **Проба прокси — читать ТЕЛО, не статус.** `curl -X POST https://push.mattermost.com/api/v1/send_push` возвращает **HTTP 200** даже на невалидный `server_id`; решение в теле: `{"status":"FAIL","error":"…"}`. HTTP-код здесь — ложный сигнал успеха. ⚠️ `DiagnosticId` ищется так (таблица **`systems`**, не `Systems`; колонка `ServerId` отсутствует): ```bash docker exec mm-postgres psql -U mmuser -d mattermost -t -c \ "SELECT name, value FROM systems WHERE name ILIKE '%diagnostic%';" ``` 🔴 **Кавычки внутри `psql -c` в ssh-строке ломаются**: `name IN ('DiagnosticId')` уходит в shell до psql → Postgres видит `column "DiagnosticId" does not exist`. Экранировать `'"'"'` либо использовать `ILIKE` с шаблоном без значения в кавычках. 📌 MM-стек на Kraken: `/home/kraken/mattermost/docker-compose.yml`, 3 сервиса (`postgres` → `mm-postgres`, `mattermost`, сеть `mm-net` → `mattermost_mm-net`). БД: `docker exec mm-postgres psql -U mmuser -d mattermost`. --- ## 🔴 `go build` = exit 0 ≠ рабочий транспорт Компилятор не видит ошибок DI-графа. `go build ./...` проходит, а приложение падает на старте с `fx.ValidateApp`. Проверять транспорт **только** реальным запуском валидации: ```go prepared, err := loadBaldaCommandConfig(true) // нужен os.Chdir в tempdir с .config/balda/config.yaml err = validateBaldaApplication(prepared) // ← вот это и есть проверка графа ``` Эталонный тест: `TestValidateCommandRunsValidationWithMattermostTransport` в `cmd/balda/command_test.go`. На нём поймано 3 дефекта, которые `go build` пропустил. --- ## 🔴 Дефект 1: `fx.In` + внешний `fx.ParamTags` **Симптом:** ``` Failed: invalid annotation function func(mattermostfx.IngressParams) (*mattermost.Ingress, error): fx.In structs cannot be annotated with fx.ParamTags or fx.From ``` **Причина:** `fx.Annotate(NewIngress, fx.ParamTags(...))`, где `IngressParams` **уже** содержит `fx.In`. Теги обязаны жить внутри структуры, снаружи аннотировать нельзя. **Неправильно:** ```go fx.Annotate(NewIngress, fx.ParamTags(``, ``, ``, `name:"balda_mattermost_enabled"`, `name:"balda_mattermost_bot_user_id"`, `name:"balda_mattermost_bot_username"`, ``), ), ``` **Правильно:** ```go // структура — теги на полях type IngressParams struct { fx.In Processor mattermost.InboundProcessor `optional:"true"` Client *mattermost.Client Commands *commandcmd.Registry // обязательный, не optional Enabled bool `name:"balda_mattermost_enabled"` BotUserID string `name:"balda_mattermost_bot_user_id"` BotUsername string `name:"balda_mattermost_bot_username"` Logger zerolog.Logger } // предоставление — голое, без fx.Annotate fx.Provide(NewIngress), ``` Эталон в апстриме: `zulip.ServerParams` + `zulip.NewServer` в `zulipfx/module.go` (строка 24) — `zulip.NewServer` объявлен **без** `fx.Annotate`. ⚠️ Поле-порт (`*commandcmd.Registry`) делать **обязательным**. `optional:"true"` на нём — лишнее: в графе он есть всегда, а optional маскирует ошибки конфигурации. --- ## 🔴 Дефект 2: нет провайдеров зарегистрированных строк **Симптом:** ``` missing types: - string[name="balda_mattermost_server_url"] (did you mean to Provide it?) - string[name="balda_mattermost_token"] - string[name="balda_mattermost_bot_user_id"] ``` **Причина:** модуль читает значения через `name:`-теги, а провайдеров этих имён в composition root нет. У Zulip они есть (`balda_zulip_*`), для нового транспорта забыли. **Правило: каждый `name:`-тег в модуле = обязательный провайдер в `app.go`.** ```go fx.Provide( fx.Annotate( func() string { return strings.TrimSpace(cfg.Balda.Mattermost.ServerURL) }, fx.ResultTags(`name:"balda_mattermost_server_url"`), ), ), ``` `strings.TrimSpace` для отсутствующего ключа YAML даёт `""` — без паники, как у Zulip. Образец: блок `balda_zulip_*` в `internal/apps/balda/app.go` (~строки 576-606). --- ## 🔴 Дефект 3: забытый импорт корневого пакета `app.go:965: undefined: mattermost` при вызове `mattermost.ValidateConfig`, если импортирован только `mattermostfx`. Транспорт требует **оба** импорта: ```go "github.com/baldaworks/balda/internal/apps/balda/channel/mattermost" "github.com/baldaworks/balda/internal/apps/balda/channel/mattermost/mattermostfx" ``` --- ## 🔴🔴 Дефект 4: объявленная команда без actor-хендлера (роняет ВЕСЬ fx-граф) Найден **только реальным запуском на Kraken** (Фаза 3). До этого Фаза 2 считалась закрытой: `go build` = 0, `go vet` = 0, `golangci-lint` = 0 issues, `go-arch-lint` = OK, тесты зелёные. **Ни один из этих гейтов его не видит.** **Симптом** — crash-loop контейнера (`Restarting (1)`), в логах одна строка на тысячи `"message":"provided"`: ``` Error: starting Balda app: could not build arguments for function ".../execution".init.func1: failed to build *execution.ActorHost: ... failed to build *command.Router: received non-nil error from function "reflect".makeFuncStub: advertised commands have no actor handler: goal ``` **Причина:** в `mattermostfx/module.go` в `commandcmd.Advertisement{Names: [...]}` была объявлена команда `goal`, которой **не существует** как actor-хендлер. `goal` — это **payload kind** (`goalkeepercmd.PayloadKindGoal`, `actorcmd.KindGoal`, `actors/job_actor.go:100`), а снаружи цикл GoalKeeper называется `goalkeeper`. **Механизм:** `actors/command/router.go:49 ValidateAdvertised()` вызывается из `commandfx/module.go:306`. Он валидирует **весь** список `Names` одного advertisement и при первом отсутствующем имени возвращает ошибку → fx не может построить `*command.Router` → падает `*execution.ActorHost` → падает приложение. Одна опечатка в списке = полный отказ старта, а не «недоступна одна команда». **Правильно — сверять `Names` с реальными `Name()`:** ```bash cd ~/Developer/balda grep -rn 'func (h \*Handler) Name() string' internal/apps/balda/actors/command/ ``` Эталонный фактический список (2026-09-26, `balda@de273d8f`): ``` locator · reset · usage · auto · cancel · close · start · user · topic · skill goalkeeper · help · plugin ``` `mattermostfx/module.go` после фикса: ```go Names: []string{ "locator", "reset", "usage", "auto", "cancel", "goalkeeper", "topic", "close", "start", "user", "skill", }, ``` `channel/mattermost/inbound.go` — тот же список в inbound allow-list, синхронно. ⚠️ `Zulip`-адаптер изначально был **прав** — там `"goalkeeper"` без `"goal"`. Регрессию внёс MM-адаптер при копировании «на глаз». ⚠️ **`docs/commands.md` — НЕ источник истины** для имён команд. Там таблица доступности по транспортам; реестр хендлеров — только `actors/command/*/handler.go`. ⚠️ Фикс — дельта-апплаером: `tools/deltas-goal-command-fix.json` (2 дельты, обе APPLIED). 📌 **Общий вывод (расширяет §«go build = exit 0 ≠ рабочий транспорт» выше):** недостаточно проверять DI-граф тестом `validateBaldaApplication`. Тест должен прогоняться **с реальным списком `Names` из модуля**. Но и это не поймало бы опечатку, если тест использует тот же неверный advertisement — поэтому единственная надёжная проверка нового транспорта = **живой запуск бинаря**. Для нового транспорта добавлять в чек-лист шаг: `docker run` (или локальный `balda start`) с включённым транспортом **до** объявления готовности. ✅ **Фикс подтверждён живым запуском (2026-09-26).** После снятия `goal` контейнер `balda-mm` на Kraken перестал падать и дошёл до рабочего состояния: ``` {"component":"balda.session_manager","balda_provider":"deepseek","message":"session manager ready"} {"component":"balda.channel.mattermost.ingress","url":"ws://mattermost:8065/api/v4/websocket", "message":"mattermost websocket connected"} {"message":"Balda bot started. Press Ctrl+C to stop."} ``` 📌 Порядок проверки, который сработал: правка дельтой → `go build ./...` → `go test ./internal/apps/balda/actors/command/...` → пересборка образа → `docker compose up -d` → `docker logs | grep -E "started|websocket connected"`. **Не** объявлять транспорт готовым по «exit 0» и «conтейнер Up» — только по этим двум строкам в логах. --- ## ⚠️ Env-override работает только по ключам, уже присутствующим в YAML `appconfig/loader.go:367` — `SetEnvPrefix("BALDA")` + `SetEnvKeyReplacer(".", "_")` + `AutomaticEnv()` **по leaf-ключам конфига**. Поэтому блок нового транспорта обязан быть в `cmd/balda/balda.yaml` — иначе `BALDA__*` переменные **молча игнорируются**. Порядок для нового транспорта: 1. блок в `cmd/balda/balda.yaml` (иначе env мёртв) 2. `MattermostConfig`-подобная структура + поле в `BaldaConfig` 3. провайдеры в `app.go` 4. `validateConfig` + вызов рядом с `validateSlackConfig` 5. включить в `validateBaldaChannelConfiguration` (`cmd/balda/command.go`) — иначе деплой только на этом транспорте отбивается «at least one channel is required» 6. `.go-arch-lint.yml` — компоненты `_transport`/`_wiring` + права у `composition_root` и `ingress_wiring` (см. §go-arch-lint выше) 7. тест на `validateBaldaApplication` с `.enabled=true` (см. §«go build ≠ …» выше) 8. тест env-override в `cmd/balda/command_test.go` (по образцу `TestLoadConfigDocument_AppliesZulipEnvOverrides`) 9. если добавлял маршрут в `BuiltinRoutes()` — обновить `TestBuiltinRoutes` 10. доки: `docs/reference/configuration.md` (ключи), `docs/commands.md` (колонка транспорта) 11. 🔴 **сверить `Advertisement.Names` с реальными `Name()`** в `actors/command/*/handler.go` — опечатка роняет весь fx-граф (см. §«Дефект 4» выше) 12. 🔴 **живой запуск** `balda start` (или `docker run`) с включённым транспортом — единственная проверка, которая ловит дефект 4 **Финальные quality gates (все обязательны, из `AGENTS.md`):** ```bash go build ./... # 0 go vet ./... # 0 go tool golangci-lint run # 0 issues go tool go-arch-lint check --project-path . # OK go test -race ./... ``` ⚠️ `go-arch-lint` **не входит** в `golangci-lint` — запускать отдельно, иначе ошибка архитектуры всплывёт только на CI/ревью. --- ## 🔴🔴 Дефект 5: `optional:"true"` прячет отсутствующую зависимость (ингресс-приёмник) Найден 2026-09-26 (Фаза 3, сессия реализации). **Самый коварный из дефектов транспорта:** приложение стартует нормально, событий не обрабатывает, и **ни один гейт этого не видит**. **Симптом:** событие `posted` доходит до ингресса, логируется как `mattermost post accepted for processing` — и дальше тишина. Ни ошибки, ни warn'а. **Причина:** в `mattermostfx/ingress.go`: ```go type IngressParams struct { fx.In Processor mattermost.InboundProcessor `optional:"true"` // ← никто не предоставляет … } ``` В `handlersfx/` были только `zulip_ingress.go` и `telegram_ingress.go` — **MM-хендлера не существовало**. fx не падает на отсутствующей `optional`-зависимости, поэтому ингресс получал `Processor == nil` и молча выходил: ```go if s.processor == nil { return // ingress.go, processPosted } ``` 📌 **`optional:"true"` — это утверждение «отсутствие допустимо», и его надо осознанно подтверждать.** Если порт обязателен для работы транспорта — маркер должен быть **снят**, тогда fx упадёт на старте и дефект будет виден сразу. **Правило для нового транспорта:** у каждого порта в `*fx`-модуле проверить, **кто его предоставляет**. Если никто — транспорт не работает, даже если `docker ps` показывает `Up`. **Фикс:** `handlersfx/module.go` — ```go fx.Annotate( newMattermostInboundHandler, fx.As(new(baldamattermost.InboundProcessor)), ), ``` (дельта `tools/deltas-mattermost-19-wire.json`). --- ## 🔴 auth: subject-based авторизация — новый транспорт надо регистрировать в трёх местах Авторизация Balda проверяет **subject** вида `:` (`telegram:101`, `zulip:42`, `slackagent::`). Для нового транспорта это три отдельные правки, и **забытая любая из них даёт молчаливый отказ авторизации** (не ошибку!): | Файл | Что добавить | Симптом при пропуске | |---|---|---| | `auth/channel_token.go` | `Channel = ""` + в `CreateMissingOwnerBindTokens` | транспорт не получает owner-bind токен | | `auth/canonical_users.go` | ветку `case Channel:` в `canonicalSubject` | 🔴 `unsupported transport ""` → **все** `IsOwnerSubject`/`GetCollaborator` = false | | `auth/owner.go` | функцию `Subject(id)` | нет канонического билдера subject'а | ⚠️ `switch channelType` в `canonicalSubject` заканчивается `default: return error`. Неизвестный транспорт — **не panic**, а тихая ошибка, которую вызывающий код часто проглатывает (`if err != nil { return false }`). Искать надо по `canonicalSubject`. 📌 Приятное: `userSubject()` в `actors/command/start/handler.go` для нового транспорта **уже работает** через default-ветку `return transport + ":" + trimmed` — команду `/start` править не нужно. ✅ **Проверка, что авторизация доехала:** в логах хендлера `settlement_stage` перестаёт быть `"unauthorized"`. До привязки владельца он именно такой — **это норма, а не баг**. --- ## 🔴 WS-фрейм без `seq`: MM перестаёт пушить события, НЕ разрывая сокет **Это была настоящая причина «connected, но тишина»** (найдена 2026-09-26 диагностическим логом каждого кадра, дельта `deltas-mattermost-15-diag.json`). ```json {"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error", "message":"Invalid sequence for WebSocket message."}} ``` раз в 30 с — ровно на каждый пинг. Балда слала `{"action":"ping"}` **без `seq`**. **Механизм:** Mattermost требует **монотонный `seq` в каждом клиентском фрейме**. На невалидном он помечает соединение сломанным и **прекращает доставлять `posted`**, при этом **сокет не закрывает** — поэтому `mattermost websocket connected` в логе остаётся, а событий нет. `seq` начинает требоваться с **первого** кадра. Если auth-челлендж убран (см. ниже), то счётчик надо начинать самому — иначе **ни один** исходящий фрейм не несёт `seq`. **Фикс** (дельта `deltas-mattermost-16-seq.json`): ```go type Ingress struct { … seq int64 // монотонный счётчик исходящих фреймов } func (s *Ingress) nextSeq() int64 { s.mu.Lock(); defer s.mu.Unlock() s.seq++ return s.seq } // Единственный путь отправки клиентского фрейма. func (s *Ingress) writeEvent(conn *websocket.Conn, action string, data any) error { frame := map[string]any{"seq": s.nextSeq(), "action": action} if data != nil { frame["data"] = data } return conn.WriteJSON(frame) } ``` + сброс `s.seq = 0` при установке нового соединения; + пинг через `s.writeEvent(conn, websocketActionPing, nil)`; + фрейм со `"status":"FAIL"` — **warn**, не игнор (иначе ошибка невидима). ✅ Проверено: `bad_seq` — 0 вхождений, в логах `pong seq_reply:1, 2, 3`. 📌 **Правило:** любая отправка в MM-сокет — **только** через `writeEvent`. «Сырой» `conn.WriteJSON`/`WriteMessage` без `seq` = тихо убитая подписка. --- ## 🔴 Диагностика: «пробник видит событие» ≠ «приложение видит событие» Отдельный урок метода, стоивший двух сессий. Пробник подключается **своим сокетом** и события получает; сокет приложения к тому моменту может быть уже «отравлен» (в нашем случае — `bad_seq`) и MM на него не пушит. Из этого родилась **ложная версия** «событие приходит, но теряется внутри `readLoop` → `handleFrame`». **Правильный диагностический приём:** временный лог **каждого кадра** в самом ингрессе: ```go s.logger.Debug(). Int("message_type", frame.messageType). Int("payload_bytes", len(frame.payload)). Str("payload_head", headOf(frame.payload, 160)). Msg("mattermost websocket frame received") ``` + лог в каждой точке отказа (`dispatchPosted`, фильтры, `MentionsBot`) — тогда «тишина» в логах перестаёт быть загадкой. Именно этот лог и вскрыл `bad_seq` за один прогон. 📌 Логи уровня `debug` **не удалять** после фикса: они уже окупились и окупятся снова. Точки отказа в `mattermost`-ингрессе: dial (с HTTP-статусом!), `frame received`, `event decoded`, `status:FAIL`, каждый `dropping posted event: <причина>`. --- ## 🔴 `go-arch-lint` — обязательный шаг для нового транспорта ⚠️ **Это не «опциональная придирка»: `AGENTS.md` прямо требует обновлять `.go-arch-lint.yml` в том же изменении, что и архитектуру.** Без правки линтер падает с двумя классами ошибок: ``` Component composition_root shouldn't depend on .../channel/mattermost File /internal/apps/balda/channel/mattermost/adapter.go not attached to any component in archfile ``` **Что добавить в `.go-arch-lint.yml` (4 правки) — по образцу `zulip`:** 1. **Секция `components:`** — пары `in:`-компонентов: ```yaml # Provider-owned Mattermost transport. It may depend only on # transport-neutral contracts; application/runtime adapters live in wiring. mattermost_transport: in: - internal/apps/balda/channel/mattermost - internal/apps/balda/channel/mattermost/presentation # Composition-root adapters for Mattermost delivery, prompt, and structured renderers. mattermost_wiring: in: - internal/apps/balda/channel/mattermost/mattermostfx ``` ⚠️ Root-пакет и `presentation/` идут в **один** компонент `_transport`, а `_fx`-пакет — отдельно в `_wiring`. Именно так у `zulip`/`telegram`/`slackagent`. 2. **Правила `mayDependOn`:** - `mattermost_transport` → `foundation, contracts, neutral_registry, mattermost_transport` - `mattermost_wiring` → `foundation, contracts, neutral_registry, application, delivery, storage, mattermost_transport, mattermost_wiring` 3. **`composition_root`** → добавить `mattermost_transport`, `mattermost_wiring` (иначе «composition_root shouldn't depend on …» — это ожидаемый отказ, там перечислены все транспорты). 4. **`ingress_wiring`** → добавить `mattermost_transport` (соседствует с `telegram_transport`, `zulip_transport`). Проверка: `go tool go-arch-lint check --project-path .` → должно быть `OK - No warnings found`. --- ## 🔴 `golangci-lint` на новом транспорте: мёртвый typing-throttle Первый прогон дал 4 замечания — все в новом коде. Важный урок: **не гейтить линтер nolint-комментарием, а удалять мёртвый код.** | Замечание | Что было | Фикс | |---|---|---| | `inamedparam` ×2 | `Start(context.Context) error` в interface-assertion | `Start(ctx context.Context) error` | | `unused` | `shouldSendTyping()` — недостижим, т.к. `SendTyping` всегда no-op | удалён | | `unused` | `websocketReceiveTimeout` — константа без использования | удалена | | `unlambda` | `func(client, logger) *Adapter { return NewAdapter(...) }` | заменено на `mattermost.NewAdapter` | **Корневая причина `unused`:** механизм typing-throttle (`shouldSendTyping`, поля `typingThrottleInterval`/`typingLastSentAt`, `typingMu sync.Mutex`, `SetTypingThrottleInterval`) был скопирован из Zulip по аналогии. Но у Mattermost **ботов нет typing API** (`SendTyping` осознанно возвращает `nil`), поэтому весь механизм недостижим. Удалён целиком — не заглушён. **Правило: при копировании транспорта с Zulip проверять каждую фичу на применимость.** Zulip-специфичное, что НЕ переносится в транспорт без поддержки: - typing-throttle (нужен интерактивный typing API) - логика `sendWithPlainFallback` — переносится, но только на content-rejection ⚠️ **Каскад после удаления полей:** удаление `typingThrottleInterval` потянуло `SetTypingThrottleInterval` → `sync` (импорт) → `zerolog` (импорт в `module.go`). Удалять надо **всей группой**, иначе серия `undefined`/`imported and not used`. --- ## ✅ Тест-регрессия: `BuiltinRoutes` и длина списка Добавление маршрута в `BuiltinRoutes()` (`deliveryfmt/routes.go`) **ломает** `TestBuiltinRoutes` в `deliveryfmt/registry_test.go`, если не обновить ожидание: ``` len(BuiltinRoutes()) = 5, want 4 ``` Фикс — добавить строку в map `want`: ```go {transport: TransportMattermost, deliveryFormat: DeliveryFormatNone}: NamePlainText, ``` 📌 Нашлось только сравнением «с правками / без правок» через `git stash` — в списке предсуществующих падений апстрима этого теста **не было**, значит это наша регрессия. --- ## ✅ 13 пакетов падают в апстриме изначально Не пытаться «чинить» как часть своей задачи — это дефект апстрима. Проверять `git stash` перед выводами о регрессии. Падающие в `baldaworks/balda@de273d8f`: - `TestInitCommand_NonInteractiveAutoSelectsRootAndGeneratesDetectedAgents` (cmd/balda) - `TestMigrateSessionMemoryLayout_*` ×4 (state) - `TestWorkspaceMCPImport/Export_UsesPersistedSessionMetadata` (session) - `TestPluginResolverResolvesContainedArgvFilteredEnvAndLaunchSecrets` (mcpruntime) - `TestResolveWorkspaceBaseBranch_ConfigPreferredWhenValidVal`, `TestCanonicalSessionMemory*` (apps/balda) **Метод проверки регрессии:** ```bash git stash push -q -m baseline --include-untracked go test ./<пакет>/ -count=1 # смотрим падения БЕЗ правок git stash pop -q # правки возвращаются ``` ⚠️ `git stash` может задеть чужие стэши — в форке лежат `whale-20260623` и `feat/zulip-events-polling`. Проверять `git stash list` до и после. --- ## Метод правок: дельта-апплаер вместо sed Правило Alex: **никаких in-place скриптов и sed**. Инструмент в `~/Developer/balda/tools/`: | Файл | Роль | |---|---| | `apply-project-deltas.py` | движок (JSON-дельты + состояние) | | `run-project-deltas.sh` | обёртка `check` / `apply` / `list` | | `deltas-.json` | группы правок | | `.deltas-state.json` | sha256 применённых якорей | ```bash cd ~/Developer/balda bash tools/run-project-deltas.sh check tools/deltas-mattermost.json tools/.deltas-state.json bash tools/run-project-deltas.sh apply tools/deltas-mattermost.json tools/.deltas-state.json ``` **Формат дельты:** ```json { "file": "путь/от/корня", "note": "что делаем", "search": "уникальный фрагмент текущего текста", "replace": "чем заменить" } ``` **Три исхода:** `replace` уже есть → `skipped` (идемпотентно); `search` ровно 1 раз → `applied`; ни того ни другого → `error: ЯКОРЬ НЕ НАЙДЕН`. 📌 `check` **обязателен перед `apply`** — на нём поймано, что дельта валидации целилась в несуществующий `validate()` в `config.go` (валидация живёт в `app.go`), и переписана до записи. ⚠️ Дельты с длинным `replace` писать в JSON через `write_file`, **не** heredoc — кавычки и `\n` ломаются молча. Это тот же питфолл, что у SSH-heredoc с вложенными кавычками. 🔴 **JSON дельт — ПЛОСКИЙ МАССИВ, не объект.** Формат-обёртка `{"deltas": [...]}` (или любой объект с ключами) роняет апплаер: ``` TypeError: string indices must be integers File "apply-project-deltas.py", line 91, in main path = root / d["file"] ``` Причина: `main()` итерирует `for i, d in enumerate(deltas)` — если `deltas` объект, `d` становится строкой-ключом. Проверено 2026-09-26 на `deltas-mattermost-14.json`. **Правильно:** файл начинается с `[`, а не с `{`. ⚠️ Обёртка `run-project-deltas.sh` требует **ТРИ** аргумента, а не два: `run-project-deltas.sh `. Без третьего — `line 9: 3: нужен файл состояния`. И `check` не поддерживает `--list`: `list` — отдельный глагол (`$1 = list`). --- ## 🔴 Mattermost WebSocket: авторизация — ОДИН механизм, не два Найдено живым запуском (Фаза 3, root-cause). Симптом: в логе `mattermost websocket connected`, но **ни одного** события `posted` в инбаунде, даже на `logger.level: debug`. При этом пробник (pure Go stdlib) ловил `posted` на том же сокете. **Две причины, наложившиеся друг на друга:** ### 1. `Origin` в handshake → 403 CORS Если клиент шлёт `Origin: http://mattermost:8065`, а `SiteURL=https://mm.qentra.top` — MM отвечает: ``` HTTP/1.1 403 Forbidden {"id":"api.web_socket.connect.upgrade.app_error","message":"URL Blocked because of CORS. Url: …"} ``` 📌 **`Origin` не слать вообще.** Балда его не шлёт — с ней этой проблемы нет. Это ловушка для пробников и внешних клиентов. ### 2. `authentication_challenge` поверх `Authorization: Bearer` → подписка умирает `channel/mattermost/ingress.go`, `streamOnce`: ```go conn, _, err := dialer.DialContext(ctx, endpoint, header) // header: Authorization: Bearer *** // … s.logger.Info().Msg("mattermost websocket connected") // ← лог УЖЕ «здоровый» conn.WriteJSON(map[string]any{ // ← 🔴 а это ломает подписку "seq": 1, "action": "authentication_challenge", "data": map[string]string{"token": token}, }) ``` **Эмпирика (пробник, `-authheader`):** - с челленджем: `hello` → `status_change` → **тишина 40 с**, хотя пост в канал создаётся; - без челленджа: `hello` → `posted` штатно. То есть избыточный челлендж заставляет MM переинициализировать сессию сокета, и `posted` на него больше не пушится. **Диагностически коварно:** `websocket connected` логируется **до** челленджа, поэтому лог выглядит здоровым. **Правило:** сокет авторизуется **либо** заголовком на dial, **либо** `authentication_challenge`. Оба сразу — тишина. Если заголовок есть, челлендж слать **нельзя**. **Фикс** — дельта `tools/deltas-mattermost-14.json` (2 дельты: убрать `WriteJSON`-блок и unused-константу `websocketActionAuthChallenge`). Образ `balda-mm:0.1.2`. 📌 **Общий урок для транспортов:** лог «connection established» ничего не говорит о том, что подписка жива. Проверять **фактом получения события**, а не фактом коннекта. Для MM — пробник из `~/Developer/balda-wsprobe/` (pure stdlib, RFC6455, собирается нативно на Mac, на хост кладётся статический бинарь). ### 🔴 MM 11.x: заголовок `Token:` для API мёртв `POST /users/login` отдаёт сессию в **заголовке ответа** `Token:`. Но использовать её нужно **только** как `Authorization: Bearer *** — заголовок `Token: ` в запросах даёт `401 api.context.session_expired.app_error` при живой сессии (в БД `expiresat` = +180 дней). Старый стиль MM 5.x больше не принимается. Проверено 2026-09-26. 📌 Отсюда же: если скрипт падает с `session_expired` — сначала проверить, каким **именем заголовка** шлётся токен, а не искать проблему в пароле/лимите сессий. --- ## 🔴 Дефект 6: точка входа онбординга, закрытая проверкой доступа Найдено 2026-09-26. `/start owner=` в DM получил от бота **«You are not allowed to use Balda in this channel.»** **Причина:** `HandleCommand` проверял доступ **до** обработки, а владельца ещё нет — тупик: привязаться нельзя, потому что не авторизован; авторизоваться нельзя, потому что не привязан. Владелец в canonical store привязывается **этой же командой**, поэтому она обязана быть доступна неавторизованному. **Неправильно:** ```go if !h.canAccess(ctx, cmd.SenderID) { return h.sendPlain(ctx, cmd.Locator, accessDeniedText) } ``` **Правильно** (образец — `zulip_ingress.go:172`): ```go if cmd.Command != commandStart && !h.canAccess(ctx, cmd.SenderID) { return h.sendPlain(ctx, cmd.Locator, accessDeniedText) } ``` ⚠️ `commandStart` **уже объявлена** в пакете `handlersfx` (приходит из telegram/zulip-веток). Своя константа даёт `commandStart redeclared in this block`. Использовать общую. 📌 **Правило для нового транспорта:** онбординг-команда — единственная, что доступна неавторизованному пользователю. Проверять это явно, иначе транспорт невозможно забутстрапить. --- ## 🔴 Дефект 7: `principal` с транспортным префиксом в `balda_user_bindings` Найдено 2026-09-26. `/start owner=` вернул успех («You are now registered as the bot owner»), но каждое обращение к боту оставалось `settlement_stage="unauthorized"`. **Механизм.** Авторизация сверяет subject вида `:`. `canonicalSubject()` **разрезает** его по первому `:` и **отбрасывает** префикс: ```go if before, after, ok := strings.Cut(trimmed, ":"); ok { channelType = strings.ToLower(strings.TrimSpace(before)) principal = strings.TrimSpace(after) // ← префикс отрезан } ``` А `GetUserByBinding` делает **точное** сравнение: ```go return s.getUser(ctx, `b.channel_type = ? AND b.principal = ?`, channelType, principal) ``` Если в БД записался `principal = 'mattermost:'` при `channel_type='mattermost'`, то поиск по `principal=''` не находит **ничего** — молча, без ошибки. 🔴 **Правило:** в `balda_user_bindings` `principal` хранится **БЕЗ** транспортного префикса. Префикс живёт только в `channel_type`. Нельзя собирать subject тем же хелпером, которым потом нормализуешь: `userSubject()` в `start/handler.go` оборачивает principal в `:` — если на вход уже пришёл обёрнутый principal, выходит двойная обёртка и запись уезжает с префиксом. **Диагностика — прочитать таблицу:** ```bash sudo -n sqlite3 /srv/…/docker/balda/state/state.db \ "SELECT channel_type, principal FROM balda_user_bindings;" # правильно: mattermost | c6udeze8dfrdxm5jbgq55bd58r # неправильно: mattermost | mattermost:c6udeze8dfrdxm5jbgq55bd58r ``` **Лечение** (`tools/mm-fix-owner-principal.sh`: бэкап → стоп → UPDATE → старт): ```sql UPDATE balda_user_bindings SET principal = substr(principal, length(channel_type) + 2) WHERE channel_type = 'mattermost' AND principal LIKE channel_type || ':%'; ``` ⚠️ Штатного пути **нет**: `AttachBinding` отказывает при существующей привязке (`count != 0` → `ErrBindingAlreadyAssigned`), detach-API отсутствует вовсе. Правка БД — вынужденная мера, делать с бэкапом и **остановленным** контейнером (sqlite не любит конкурентной записи). --- ## 🔴 Провайдер: `base_url` в YAML мёртв, работает только `OPENAI_BASE_URL` Найдено 2026-09-26. Симптом (образ `0.1.7`): ```json {"component":"balda.turn_dispatcher", "error":"agent run: send openai request: Post \"https://api.openai.com/v1/chat/completions\": tls: failed to verify certificate: x509: certificate signed by unknown authority"} ``` Запрос уходил на **`api.openai.com`**, хотя в `config.yaml` стоял `base_url: "https://api.deepseek.com/v1"`. **Причина:** тип `LocalAPIConfig` в `github.com/normahq/runtime/v2@v2.0.11/agentconfig/config.go` имеет **только** `api_key` и `model`. Поля `base_url` в схеме **нет**: ```go type LocalAPIConfig struct { APIKey string `json:"api_key,omitempty" yaml:"api_key,omitempty" mapstructure:"api_key"` Model string `json:"model,omitempty" yaml:"model,omitempty" mapstructure:"model"` } ``` Базовый URL читается **только из env** — `hostedagent/openai.go`: ```go const defaultOpenAIBaseURL = "https://api.openai.com/v1" func openAIBaseURL() string { if u := os.Getenv("OPENAI_BASE_URL"); u != "" { return strings.TrimRight(u, "/") } return defaultOpenAIBaseURL } ``` 📌 **Правило:** для любого OpenAI-совместимого провайдера (DeepSeek, локальные gateway и т.п.) базовый URL задавать **через `OPENAI_BASE_URL`** в `.env` или `environment:` compose. Ключ `base_url` в YAML **выглядит рабочим, но не делает ничего** — молчаливая ловушка. ⚠️ Сама переменная попадает в процесс только если `env_file`/`environment` настроены. **Фикс:** `OPENAI_BASE_URL=https://api.deepseek.com/v1` в `/home/kraken/balda/.env` (`tools/mm-add-base-url.sh`, идемпотентный, с бэкапом). --- ## 🔴 `FROM scratch` и TLS: нет доверенных корней → любой HTTPS падает `Dockerfile.release` Балды собирается из `scratch`. В финальном образе **нет `/etc/ssl/certs`**, поэтому попытка установить любое TLS-соединение заканчивается: ``` tls: failed to verify certificate: x509: certificate signed by unknown authority ``` Ошибка **обманчива**: выглядит как «удалённый сервер с неверным сертификатом», а на деле в образе физически нет файла с корневыми CA — Go не может построить цепочку доверия. Симптом проявился ровно один раз, на первом исходящем HTTPS (запрос к LLM). **Фикс — дельта `tools/deltas-mattermost-22-ca.json`:** ```dockerfile FROM scratch # CA bundle: the scratch base has no trust store, so any HTTPS call (the LLM # provider in particular) fails with 'x509: certificate signed by unknown # authority'. The builder stage is a Debian image and carries the bundle. COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /out/balda /usr/local/bin/balda ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt ENTRYPOINT ["/usr/local/bin/balda"] ``` 📌 **Правило:** любой `scratch`/`distroless`-образ, делающий исходящие HTTPS-запросы, обязан содержать `ca-certificates.crt` **и** выставлять `SSL_CERT_FILE` (у distroless путь тот же). Копировать прямо из builder'а — он на Debian и бандл уже там. ⚠️ Альтернатива для локального дебага: `SSL_CERT_FILE` с хоста через bind-mount. Для прода — копировать в образ. --- ## Метод: чтение sqlite-состояния Балды (образ `scratch`) В образе **нет** ни `sh`, ни `sqlite3`, поэтому `docker exec` невозможен в принципе: ``` OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH ``` **Рабочий порядок:** 1. Файлы состояния: `/state.db` + `state.db-wal` + `state.db-shm`, владелец — **root**. 2. `sqlite3` **есть на хосте** Kraken (`/usr/bin/sqlite3`), но не в контейнере. 3. Для остановленного контейнера — прямой `sqlite3` под `sudo -n`. 4. Для живого — `strings` (только просмотр, без записи). 🔴 **Читать `state.db` ВМЕСТЕ с `state.db-wal`.** Свежие записи живут в WAL, а основной файл выглядит пустым. Прямая потеря времени: таблица `balda_user_bindings` казалась пустой, хотя привязка владельца уже была записана — она лежала в `-wal`. ```bash STATE=/srv/…/docker/balda/state for f in state.db state.db-wal state.db-shm; do echo "--- $f" sudo -n strings "$STATE/$f" 2>/dev/null \ | grep -oE "(telegram|mattermost|slackagent|zulip):[A-Za-z0-9_-]+" | sort -u done ``` **Полезные ключи KV внутри `state.db`:** - `balda.appowner_auth_token` — owner auth token (32 символа), нужен для `/start owner=`; - `balda_user_bindings` — привязки, `principal` **без** префикса (см. §Дефект 7); - `balda_user_binding_claims` — claim'ы, `generated_binding_count`, `role IN ('administrator','operator')`. ⚠️ `sudo` через ssh требует `< /dev/null`, иначе `Connection closed by remote host`. ⚠️ Правка каталога данных (`sudo cp -a state `) уходит на апрув и **блокируется** по таймауту. Точечный `UPDATE` одного поля такого апрува не требует — предпочтителен. --- ## 🔴 Mattermost WS: `bad_seq` — пинг без `seq` убивает подписку молча Найдено 2026-09-26 диагностическим логом каждого кадра (`deltas-mattermost-15-diag.json`). **Это и была настоящая причина «connected, но тишина»** — до неё ошибочно считалось, что виноват `authentication_challenge` (он тоже вреден, но лишь как усугубляющий фактор). MM отвечал раз в 30 секунд, ровно на каждый пинг: ```json {"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error", "message":"Invalid sequence for WebSocket message."}} ``` **Механизм:** Mattermost требует **монотонный `seq` в КАЖДОМ клиентском фрейме**. Балда слала `{"action":"ping"}` **без `seq`** → MM помечал соединение сломанным и **прекращал доставлять `posted`**, при этом **сокет не закрывал**. Поэтому `mattermost websocket connected` в логе остаётся, а событий нет. Особенно коварно после снятия челленджа: счётчик `seq` пропадал вместе с ним, и **ни один** исходящий фрейм его не нёс. **Фикс** (`deltas-mattermost-16-seq.json`): поле `seq int64`, `nextSeq()` под мьютексом, единый `writeEvent(conn, action, data)`, сброс `seq = 0` при новом соединении, пинг через `writeEvent`, а фрейм со `status:FAIL` логируется **warn**, а не игнорируется. 📌 **Правило:** любая отправка в MM-сокет — **только** через `writeEvent`. «Сырой» `conn.WriteJSON`/`WriteMessage` без `seq` = тихо убитая подписка. 📌 **Общий урок:** лог «connection established» не значит, что подписка жива. Проверять **фактом получения события**, а не фактом коннекта. Временный лог каждого кадра окупается сразу — и его стоит оставить после фикса. --- ## 🔴 Тест-ассерт — это гипотеза, а не истина (PR-проход 2026-09-26) Дважды за сессию падал **новый** тест, и оба раза неверным оказывался **ассерт**, а не код. Соблазн «поправить код под тест» — прямой путь сломать работающее. | Падающий тест | Моя гипотеза | Реальность | |---|---|---| | «тред и корневой пост дают одну сессию» | тред — часть канальной сессии | тред — **отдельная** сессия: корневой пост = `mm-c-a4aa02ef`, тред = `mm-t-89bbf64f`; при этом два **разных** ответа в треде дают ОДНУ сессию `mm-t-89bbf64f` — это и есть контракт | | «`NewIngress` фолбэчит `botUserID` из клиента» | есть fallback | `onStart` **отказывается стартовать** без `bot_user_id` (guard в ingress.go) | **Процедура при падении нового теста:** 1. Написать одноразовый пробник, печатающий **фактическое** поведение (`t.Logf` всех вариантов), а не утверждающий желаемое. 2. Сверить с комментарием/доком функции — там часто уже написано, чему код **обязан** соответствовать. 3. Только потом решать: ассерт или код. ⚠️ Пробник класть в пакет и **удалять** после (`rm mm_locator_probe_test.go`) — иначе мусор в PR. ## 🔴 `goconst` / `dupword` в новых тестах `golangci-lint` валит даже тестовые файлы. На MM-тестах поймано 4 замечания, все — в моём новом коде: | Линтер | Что | Фикс | |---|---|---| | `goconst` | `"channel-1"` — 15 вхождений, `"user-1"` — 3, `"dm-channel-1"` — 3 | константы `testTeamID`/`testChannelID`/`testDMChannelID`/`testUserID` в пакете | | `dupword` | `"thread channel = %q, channel channel = %q"` — повтор слова `channel` | переформулировать: «thread and channel locators resolve to different channels» | 📌 Линтер **подсказывает** имя существующей константы: `string 'channel-1' has 4 occurrences, but such constant 'testChannelID' already exists`. Заводить константы сразу при написании тестов дешевле, чем добивать потом. ## 📌 Артефакты деплоя — вон из PR `tools/mm-*.sh` (9 ops-скриптов) и `tools/deltas-*.json` — **локальный инструментарий**, в PR в апстрим не идут. В коммиты PR шли только: код транспорта, wiring, тесты, доки. ⚠️ **Поправка (та же сессия, позже):** я сначала написал, что `tools/` «оставлены незакоммиченными». Это было **неверно** — проверка показала, что **16 файлов `tools/` уже сидят в базовом коммите `004fda2f`** (из прошлой сессии), и в апстриме каталога `tools/` **нет вообще** (`git ls-tree upstream/main tools/` → пусто). То есть в PR уезжал мусор, а `git status` этого не показывал. ## 🔴 Проверять СОСТАВ PR, а не рабочее дерево **Правило:** перед объявлением «готово к PR» смотреть содержимое коммитов, а не только `git status`: ```bash git diff --stat upstream/main...HEAD # ЧТО реально уедет в PR git ls-tree --name-only upstream/main # есть ли этот dir в апстриме вообще git log --oneline --name-only ^..HEAD -- '/' # не моё ли ``` Три команды отвечают на три разных вопроса, и **`git status` не отвечает ни на один**: он показывает рабочее дерево, а не то, что уже закоммичено ранее (в т.ч. чужой рукой). 📌 **Симптом промаха:** «я всё вычистил, PR готов» → `git diff --stat` показывает 56 файлов вместо 40. Лишние пришли из **предыдущей** сессии, не из текущей. 📌 **Полезное следствие:** `git log --name-only -- '/'` точно отделяет **свой** мусор от **унаследованного** — без этого легко начать «чинить» не то. ## ⚠️ `git rm --cached` требует апрува — не повторять вслепую `git rm -r --cached tools/` идёт на подтверждение; при таймауте **не применяется** (безопасно — состояние целое, проверять `git status --short`). Не ретраить автоматически: удаление из индекса — операция, требующая команды заказчика. Альтернатива без «удаления»: оставить файлы untracked, а из индекса убрать одним явным коммитом по команде Alex. ## 🔴 PR-гигиена: проверять состав ветки, а не свои коммиты База ветки `004fda2f` (коммит **прошлой сессии**) затащила в PR **16 файлов `tools/`** (`apply-project-deltas.py`, `run-project-deltas.sh`, `deltas-mattermost*.json`, `.deltas-state.json`). В апстриме каталога `tools/` **нет вообще** — проверка: `git ls-tree upstream/main tools/` → пусто. 🔴 **Свои коммиты были чистыми**, но ветка — нет: ```bash git log --oneline --name-only upstream/main..HEAD -- 'tools/' # → показал базу, не мои коммиты ``` Поэтому смотреть надо **состав PR**, а не свои коммиты: ```bash git diff --name-only upstream/main...HEAD # все файлы PR git diff --name-only upstream/main...HEAD | grep -v '^tools/' # что должно остаться ``` **Фикс — два шага, оба обязательны:** ```bash echo '/tools/' >> .gitignore git rm -r --cached tools/ # 🔴 файлы ОСТАЮТСЯ на диске, уходят только из git ``` ⚠️ **`gitignore` НЕ отвязывает уже отслеживаемые файлы.** Без `git rm --cached` файлы останутся в индексе навсегда, сколько ни игнорь их. ⚠️ `git rm -r --cached` уходит на **апрув** (рекурсивное удаление) — если апрув истёк, команда **не выполнилась**, состояние целое; проверить `git diff --cached --name-only` до повтора. ⚠️ Артефакты деплоя (`mm-*.sh` ops-скрипты) в PR не шлём — только код транспорта, wiring, тесты, доки. ## 🔴 Целевой репозиторий определять через API, не по имени remote'а Два remote'а выглядели так: `origin` = `mallexxx/balda` (форк), `upstream` = `normahq/balda`. Но **реальный родитель форка — `baldaworks/balda`**, а `normahq/balda` — просто редирект: ```bash gh repo view mallexxx/balda --json parent # → parent: baldaworks/balda gh repo view normahq/balda --json owner # → owner.id: O_kgDOEgAHrw ← тот же org, что у baldaworks/balda = редирект, не отдельный репо ``` 📌 Создавать PR в **`baldaworks/balda`**, а `--head` указывать как `mallexxx:` («владелец:ветка»). Полный URL форка в `--head` ломает `gh`. ```bash gh pr create --repo baldaworks/balda --base main \ --head mallexxx:feat/mattermost-channel \ --title "…" --body-file /tmp/pr-body.md ``` ⚠️ **Проверять базу перед пушем:** `git rev-list --left-right --count upstream/main...HEAD` → `0 7` значит «0 позади, 7 впереди» — идеальная база, rebase не нужен. ## 🔴 CI на чистом раннере — это НАСТОЯЩАЯ приёмка сборки (шире локальных гейтов) Локально `cmd/balda` падал на `TestInitCommand_NonInteractiveAutoSelectsRootAndGeneratesDetectedAgents`. В CI (чистый раннер, свой образ) **тот же тест прошёл**. Локальные гейты (`build`/`vet`/`golangci-lint`/`go-arch-lint`/`go test`) — **необходимое, но не достаточное** условие. Они видят состояние **моей** машины: остатки кэшей, локальные конфиги, переменные окружения, версии инструментов. CI видит чистое дерево. **Пайплайн в `baldaworks/balda` (8 джобов):** `architecture-lint`, `lint`, `test`, `ingress-e2e`, `jetstream-integration`, `postgres-integration`, `sqlite-integration`, `projection-replay`. 📌 **Порядок:** локальные гейты → push → **дождаться CI** → только потом говорить «готово». Не объявлять готовность по локальным гейтам, если в репо есть CI. ⚠️ Локальный фейл, которого нет в CI, — **не повод чинить код**: сначала проверить, не особенность ли это локальной среды (`git stash` + повтор, или прогон в контейнере). ## Связанные заметки - [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2, где всё это найдено - [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3, деплой на Kraken - [[personal/plans/balda-mm-endtoend-plan]] — сквозной путь `@balda` → ответ - [[personal/projects/balda/balda-mm-delegation-plan]] — общий план - [[personal/tech/hermes-secret-redaction-bypass]] — маскировка секретов в tool-выводе --- ## Диаграмма потока ингресса (для отладки нового транспорта) ``` MM websocket frame ↓ readLoop (frame.messageType == TextMessage?) ↓ handleFrame ← лог «event decoded»; status:FAIL → warn ↓ dispatchPosted (IsBotEcho / IsSystemPost / IsDeletedPost → drop с логом) ↓ processPosted (MentionsBot / StripMention → drop с логом) ↓ InboundProcessor.ProcessInbound ← 🔴 ТУТ был nil (Дефект 5) ↓ ingressapp.Service.Process ├─ Authorizer.Authorize ← subject + canonicalSubject (Дефекты 3, 7) ├─ SessionPreparer.Prepare ← GetSession → RestoreSession → EnsureSession └─ Dispatcher.Dispatch ← durable envelope ↓ turn_dispatcher → provider (OPENAI_BASE_URL + CA, Дефект F) ``` **Точки отказа, которые надо проверять по порядку** — сверху вниз, по логам: `frame received` → `event decoded` → `posted event accepted` → `post accepted for processing` → `conversational ingress settled` (`settlement_stage`!) → `assembled provider user content` → `turn task failed`. Пока нет строки `assembled provider user content` — провайдер не виноват.