Files
obsidian-vault/personal/tech/balda-go-fx-pitfalls.md
T
Taiga b53dd70a15 [2026-09-26 09:10:28] syncthing: personal/tech/archive-org-collection-harvest.md personal/tech/balda-go-fx-pitfalls.md personal/tech/bilety-oxota-interaktiv.md personal/tech/cloudflare-tunnel-ssh-published-route.md personal/tech/deepseek-language-drift.md personal/tech/docker-data-root-mount-race.md personal/tech/docker-mac-disk-issues.md personal/tech/docker_colima_setup.md personal/tech/eagle-dashboard-launchd.md personal/tech/hermes-agent-improvements.md personal/tech/hermes-browser-toolsets.md personal/tech/hermes-fork-vs-upstream.md personal/tech/hermes-git-repo.md personal/tech/hermes-memory-architecture.md personal/tech/hermes-secret-redaction-bypass.md personal/tech/hermes-self-improvement-review.md personal/tech/home-network-config.md personal/tech/inter-site-throughput-taiga-to-mac.md personal/tech/inter-site-throughput-taiga-to-mac.md.bak-nedstab2 personal/tech/jellyfin-db-schema-mismatch.md personal/tech/karabiner-keyboard-config.md personal/tech/kraken-media-inventory-facts.md personal/tech/kraken-truenas-media-copies.md personal/tech/lampa-jellyfin-plugin.md personal/tech/lampac-deployment.md personal/tech/lampac-torrserver.md personal/tech/llm-model-selection-2026.md personal/tech/mac-print-shared-services.md personal/tech/modbus-rtu-sniffer-debugging.md personal/tech/multistream-single-flow-limit.md personal/tech/multistream-single-flow-limit.md.bak-nedstab2 personal/tech/music-tags-repair-truenas.md personal/tech/obsidian-mcp-ecosystem.md personal/tech/original-xbox-2025-guide.md personal/tech/prowlarr-flaresolverr-cloudflare.md personal/tech/radarr-sonarr-phantom-records.md personal/tech/roundtrip-key-verification.md personal/tech/ryujinx-mac-setup.md personal/tech/skill-arr-media-stack-setup-additions.md personal/tech/skill-docker-data-root-mount-race.md personal/tech/skill-lampac-kraken-deploy.md personal/tech/tirith-trust.md personal/tech/truenas-local-tree-comparison.md personal/tech/truenas-nfs4-acl-and-arrmultiuser.md personal/tech/truenas-zfs-panic-recovery.md personal/tech/vault-doc-pruning.md personal/tech/vless-space-subscription-egress.md personal/tech/watchlist-sync-feed.md personal/tech/watchtower-docker-updates.md personal/tech/xray-outbound-subscription-3xui.md personal/tech/xray-reverse-limits-private-network.md personal/tech/xray-reverse-tunnel-kraken-truenas.md personal/tech/xray-reverse-tunnel-kraken-truenas.md.bak-nedstab2 personal/tech/yandex-music-api.md
2026-09-26 09:10:28 +00:00

1301 lines
79 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`) **сама** добавляет префикс вида `<channel>:<id>`,
а `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:<id>")
// yields the same subject, so callers may pass either a raw user ID or a subject
// without risking a doubled prefix ("mattermost:mattermost:<id>").
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`.
**Общий приём для нового транспорта:** любой `<X>Subject(id)`-билдер делать
**идемпотентным** — срезать уже существующий `<x>:`-префикс. Тогда вызывающий код может
передавать и raw id, и готовый subject без риска двойного префикса. Это дешевле, чем
искать все call-site'ы, и переживает добавление новых.
**Регресс-тесты:** `TestMattermostSubjectIsIdempotent` (`handlersfx/mattermost_ingress_test.go`),
`TestMattermostHandlerSendsChannelQualifiedPrincipalExactlyOnce`.
⚠️ Тот же класс ошибки — двойное применение helper'а, который сам добавляет префикс.
Признак: строка вида `<x>:<x>:<id>` в БД, в `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. `<X>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-<hash(channel)>`,
ответ в треде (`RootID=<root>`) → `mm-t-<hash(channel|root)>`. **Тред — отдельная беседа
от канала.** Соответствует комментарию в `locator_codec.go`, но означает: бот в треде ведёт
сессию, отдельную от канала. Зафиксировано тестом.
---
## 🔴🔴 Дефект 8: замкнутый круг привязки, когда binding занят чужим аккаунтом
Ситуация: владельцем привязан **не тот** аккаунт, а нужно привязать правильный.
Все штатные пути оказываются закрыты.
| Штатный путь | Почему не работает |
|---|---|
| `/start owner=<owner_auth_token>` | `HasOwner()` = `len(OwnerSubjects()) != 0`. Владелец существует → код уходит в `handleExistingOwnerState` → `IsOwner(<нужный аккаунт>)` = false → **«Bot owner is already registered»** |
| `/start <channel_token>` для нужного канала | `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_<X>_*` переменные **молча игнорируются**.
Порядок для нового транспорта:
1. блок в `cmd/balda/balda.yaml` (иначе env мёртв)
2. `MattermostConfig`-подобная структура + поле в `BaldaConfig`
3. провайдеры в `app.go`
4. `validate<X>Config` + вызов рядом с `validateSlackConfig`
5. включить в `validateBaldaChannelConfiguration` (`cmd/balda/command.go`) —
иначе деплой только на этом транспорте отбивается «at least one channel is required»
6. `.go-arch-lint.yml` — компоненты `<x>_transport`/`<x>_wiring` + права у
`composition_root` и `ingress_wiring` (см. §go-arch-lint выше)
7. тест на `validateBaldaApplication` с `<x>.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** вида `<channel>:<principal>` (`telegram:101`,
`zulip:42`, `slackagent:<team>:<user>`). Для нового транспорта это три отдельные правки,
и **забытая любая из них даёт молчаливый отказ авторизации** (не ошибку!):
| Файл | Что добавить | Симптом при пропуске |
|---|---|---|
| `auth/channel_token.go` | `Channel<X> = "<name>"` + в `CreateMissingOwnerBindTokens` | транспорт не получает owner-bind токен |
| `auth/canonical_users.go` | ветку `case Channel<X>:` в `canonicalSubject` | 🔴 `unsupported transport "<x>"` → **все** `IsOwnerSubject`/`GetCollaborator` = false |
| `auth/owner.go` | функцию `<X>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-<topic>.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 <check|apply|list> <deltas.json> <state.json>`.
Без третьего — `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: <session>` в запросах даёт
`401 api.context.session_expired.app_error` при живой сессии (в БД `expiresat` = +180 дней).
Старый стиль MM 5.x больше не принимается. Проверено 2026-09-26.
📌 Отсюда же: если скрипт падает с `session_expired` — сначала проверить, каким **именем
заголовка** шлётся токен, а не искать проблему в пароле/лимите сессий.
---
## 🔴 Дефект 6: точка входа онбординга, закрытая проверкой доступа
Найдено 2026-09-26. `/start owner=<token>` в 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=<token>` вернул успех («You are now registered as the bot
owner»), но каждое обращение к боту оставалось `settlement_stage="unauthorized"`.
**Механизм.** Авторизация сверяет subject вида `<channel>:<principal>`. `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:<user_id>'` при `channel_type='mattermost'`,
то поиск по `principal='<user_id>'` не находит **ничего** — молча, без ошибки.
🔴 **Правило:** в `balda_user_bindings` `principal` хранится **БЕЗ** транспортного префикса.
Префикс живёт только в `channel_type`. Нельзя собирать subject тем же хелпером, которым
потом нормализуешь: `userSubject()` в `start/handler.go` оборачивает principal в
`<transport>:<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_dir>/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=<token>`;
- `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 <dir>`) уходит на апрув и **блокируется** по
таймауту. Точечный `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> # есть ли этот dir в апстриме вообще
git log --oneline --name-only <mой_первый_коммит>^..HEAD -- '<dir>/' # не моё ли
```
Три команды отвечают на три разных вопроса, и **`git status` не отвечает ни на один**:
он показывает рабочее дерево, а не то, что уже закоммичено ранее (в т.ч. чужой рукой).
📌 **Симптом промаха:** «я всё вычистил, PR готов» → `git diff --stat` показывает
56 файлов вместо 40. Лишние пришли из **предыдущей** сессии, не из текущей.
📌 **Полезное следствие:** `git log --name-only <range> -- '<dir>/'` точно отделяет
**свой** мусор от **унаследованного** — без этого легко начать «чинить» не то.
## ⚠️ `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:<branch>`
(«владелец:ветка»). Полный 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` — провайдер не виноват.