From 519f7c443c495ee1cb69f1b10e0302ea93670fc2 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sat, 26 Sep 2026 14:31:57 +0600 Subject: [PATCH] [2026-09-26] eagle: personal/plans/balda-mm-pr-plan.md personal/projects/balda/balda-mattermost-connector.md personal/tech/balda-go-fx-pitfalls.md --- personal/plans/balda-mm-pr-plan.md | 135 ++++++++++++------ .../balda/balda-mattermost-connector.md | 114 ++++++++++++++- personal/tech/balda-go-fx-pitfalls.md | 99 +++++++++++++ 3 files changed, 301 insertions(+), 47 deletions(-) diff --git a/personal/plans/balda-mm-pr-plan.md b/personal/plans/balda-mm-pr-plan.md index 376122c2..0d788c40 100644 --- a/personal/plans/balda-mm-pr-plan.md +++ b/personal/plans/balda-mm-pr-plan.md @@ -1,68 +1,111 @@ -# Balda — Mattermost connector: план подготовки PR в апстрим +# Balda — Mattermost connector: PR-подготовка (закрыто) -> Создан 2026-09-26. Статус: **в работе**. +> Создан 2026-09-26. Обновлён 2026-09-26 (PR-проход). Статус: **✅ ЗАКРЫТО — код, тесты, доки, коммиты готовы**. > Команда Alex: «Коммит текущего стейта и продолжай пока не стыдно будет открывать PR mattermost connector. > Со всеми поддерживаемыми фичами и тестами. Вперёд! Без вопросов и требующих апрува команд.» > Часть [[personal/projects/balda/balda-mm-delegation-plan]] (Фаза 2 → PR в апстрим). -## Цель +## Итог -PR в `baldaworks/balda`: полный Mattermost-транспорт — все поддерживаемые фичи Балды, -тесты, зелёные quality gates, доки. Ветка `feat/mattermost-channel`, 1 коммит уже есть -(`004fda2f`), рабочий счёт — 11 изменённых + 21 новых файлов в worktree. +Ветка `feat/mattermost-channel`, **6 коммитов** (базовый `004fda2f` + 5 новых). Готово к `git push` и PR. -## Что уже готово (не переделывать) +``` +58f27120 docs(mattermost): document the transport setup and behavior +80d4f41d test(mattermost): cover the transport, the ingress and the handler +c7f18654 fix(mattermost): send a monotonic seq on every client frame +6dc3077a feat(mattermost): add the inbound handler and wire it into DI +fa1a2855 feat(auth): support the Mattermost channel subject +004fda2f feat(mattermost): add Mattermost transport adapter ← база +``` -- Пакет `channel/mattermost/` (client, locator_codec, inbound, adapter, media, ingress, formatting, presentation, mattermostfx) — закоммичен `004fda2f`, 40 файлов. -- Все quality gates были зелёные на `004fda2f`. -- 6 причин тишины найдены и устранены живым запуском на Kraken (см. `balda-kraken-mm-deploy.md`). -- `handlersfx/mattermost_ingress.go` — написан в worktree, НЕ закоммичен. +## Quality gates — все зелёные (проверено на закоммиченном дереве) -## Пробелы, найденные ревизией кода (2026-09-26, вечер) +``` +go build ./... → exit 0 +go vet ./... → exit 0 +go tool golangci-lint run → 0 issues +go tool go-arch-lint check --project-path . → OK - No warnings found +go test -race ./channel/mattermost/ ./handlersfx/ ./auth/ ./deliveryfmt/ + ./actors/command/... → все ok, включая start и auth +``` -### P1 — дефекты, которые полезут в PR и подведут ревью +## Что сделано в этом проходе -| # | Дефект | Где | Почему важно | -|---|---|---|---| -| P1.1 | Возможен двойной транспортный префикс в `principal` | `actors/command/start/handler.go:~420` | Прошлый фикс был только в БД → дефект вернулся. В PR пойдёт сырой код | -| P1.2 | `channel/mattermost/*_test.go` — **0 файлов** | `channel/mattermost/` | «Со всеми тестами» — сейчас только 2 теста в `cmd/balda`. Апстрим-транспорт zulip имеет ~4 тест-файла | -| P1.3 | `handlersfx/mattermost_ingress.go` не покрыт тестами и не закоммичен | `handlersfx/` | Ядро интеграции, написанное «на веру» | -| P1.4 | README / `docs/balda.md` не упоминают Mattermost | корень, docs/ | Требование `AGENTS.md`; осталось открытым из Фазы 2 | -| P1.5 | `SendDraftPlain` — проверить, что edit-in-place покрыт | `adapter.go` | Отклонение от плана, надо подтвердить тестом | -| P1.6 | `SendTyping` — no-op; в `ProgressPolicy` Typing:false | `adapter.go`, `inbound.go` | Проверить, что нет мёртвого кода (линтер уже проходил, но повторно) | +### 1. Дефект двойного префикса — починен В КОДЕ, а не в БД (главное) -### P2 — качество PR +Прошлый фикс был прямым `UPDATE` в `state.db`, поэтому дефект вернулся. +Теперь источник закрыт: -- Прогнать `go test -race ./...` на затронутых пакетах, отделить апстримные фейлы (`git stash`). -- `golangci-lint`, `go-arch-lint` — перепроверить после новых файлов. -- Конвенциональные коммиты, разбивка: transport code / wiring / tests / docs. -- Убрать из дерева артефакты, которым в PR не место (`tools/mm-*.sh`, дельта-JSON) — - решить: в отдельный коммит с `tools/` или не пушить вовсе. +- `auth.MattermostSubject()` **идемпотентен** — срезает уже имеющийся префикс + `mattermost:` перед повторной квалификацией; +- из `actors/command/start/handler.go` убран локальный `TrimPrefix`-костыль, + ветка стала как у остальных транспортов: `auth.MattermostSubject(trimmed)`. -## План работ (по шагам) +Почему это ломало прод: `canonicalSubject` режет subject по **первому** `:`, а +`GetUserByBinding` сравнивает **точно**. Значение `mattermost:mattermost:` +в `principal` не совпадает ни с чем → каждый пост Alex отбивался +`settlement_stage=unauthorized`. +**Тест-регрессия:** `TestMattermostSubjectIsIdempotent` +(`handlersfx/mattermost_ingress_test.go`) + `TestMattermostHandlerSendsChannelQualifiedPrincipalExactlyOnce`. -1. **Зафиксировать базу.** `check` + `apply` всех дельт в worktree → `go build ./...`. -2. **Починить P1.1** (двойной префикс) в коде `handler.go`. -3. **Тесты транспорта** (`channel/mattermost/`): `locator_codec_test.go`, `inbound_test.go`, `adapter_test.go` (по образцу zulip, `httptest`-сервер для REST). -4. **Тесты хендлера** (`handlersfx/mattermost_ingress_test.go`). -5. **P1.4** — README + `docs/balda.md`. -6. **Quality gates** — build / vet / golangci-lint / go-arch-lint / race-тесты. -7. **Коммиты** — по смыслу, Conventional Commits. -8. **Обновить Obsidian** — итог и что осталось. +### 2. Исправлено противоречие `ProgressPolicy.Typing` -## Definition of Done +Command-путь ставил `Typing: true`, тогда как inbound-путь — `false`, а +`SendTyping` у MM-адаптера **осознанный no-op** (у ботов MM нет typing API). +Мёртвая ветка + warn-шум на каждом ходу. Приведено к `false`. -- `go build ./...`, `go vet ./...` = 0. -- `golangci-lint run` = 0 issues. -- `go-arch-lint check --project-path .` = OK. -- Тесты транспорта + хендлера зелёные, `-race`. -- Ни одного дефекта из `balda-go-fx-pitfalls` в коде. -- Доки описывают Mattermost. -- Коммиты в `feat/mattermost-channel`, готово к `git push` и PR. +### 3. Тесты транспорта — 6 новых файлов (~90 тестов) + +| Файл | Покрытие | +|---|---| +| `channel/mattermost/locator_codec_test.go` | локаторы, **thread↔channel изоляция**, `LocatorFromAddressKey`, scope, UserID | +| `channel/mattermost/inbound_test.go` | `NormalizeInbound`, mention-фильтр, `ParseCommand`, whitelist команд, `PostIDFromWebSocket` | +| `channel/mattermost/adapter_test.go` | все `deliverycmd.Operation`, **plain-fallback только на content-rejection**, draft-as-post, typing no-op без сети, `root_id` в треде | +| `channel/mattermost/client_test.go` | ws/wss derivation, `Bearer`, `APIError`, `isContentRejectedError`, контекст | +| `channel/mattermost/ingress_test.go` | 🔴 **`bad_seq`-регрессия**, монотонность и уникальность `seq` под 16 горутинами, фильтры, panic-recovery, семафор | +| `handlersfx/mattermost_ingress_test.go` | публикация всех команд, **одиночный** префикс principal, `/start` до авторизации, отказ чужим, nil-safety | + +### 4. Доки — `README.md`, `docs/balda.md`, новый `docs/mattermost.md` + +`docs/mattermost.md` — полный гайд: создание бота, минтинг токена (отдаётся +ОДИН раз), membership в команде/канале, 5 ключей конфига, поведение в +DM/канале/треде, troubleshooting (`bad_seq`, `unauthorized`, `x509`). + +## Уроки метода (дополнить `balda-go-fx-pitfalls`) + +1. **Тест-ассерт — это гипотеза, а не истина.** Дважды падал тест, и оба раза + неверным был ассерт, а не код: (а) «тред и корневой пост = одна сессия» — + на самом деле тред **отдельная** сессия от канала (проверено пробником: + `mm-c-a4aa02ef` vs `mm-t-89bbf64f`, два разных ответа в треде дают ОДНУ + сессию); (b) «`NewIngress` фолбэчит `botUserID` из клиента» — на самом деле + `onStart` **отказывается стартовать** без `bot_user_id`. Прежде чем править + код под падающий тест — проверить, чему код **обязан** соответствовать. +2. **`goconst` в тестах** — повторяющиеся строковые литералы (`"channel-1"` ×15) + валят линтер. Заводить константы (`testChannelID` и т.п.) сразу. +3. **`dupword`** — сообщение `"thread channel = %q, channel channel = %q"` + поймано линтером как повтор слова. Формулировать иначе. +4. **`write_file` + `***` в доке** — плейсхолдеры вида `BALDA_MATTERMOST_TOKEN=***` + в примерах конфига остаются на диске **целыми**; искажается только вывод + чтения. Проверять `awk 'NR==N'`, не глазами (см. `hermes-secret-redaction-bypass`). +5. **Артефакты деплоя — вон из PR.** `tools/mm-*.sh` (9 скриптов) и + `tools/deltas-*.json` (15 файлов) оставлены **незакоммиченными**: это + ops-инструменты и метод правок, в апстрим-репозиторий им не место. + +## Что осталось + +**Ничего по коду.** Дальше по желанию Alex: + +1. `git push -u origin feat/mattermost-channel` и открыть PR в `baldaworks/balda`. +2. Фаза 3/4 (эксплуатация на Kraken) — [[personal/projects/balda/balda-kraken-mm-deploy]]. + +⚠️ Незакоммиченные `tools/mm-*.sh` + `tools/deltas-*.json` — **локальный +инструментарий**. Если они нужны в репо — отдельным коммитом с пометкой, но +для PR в апстрим я бы их не отправлял. ## Связанные заметки -- [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2 (код) -- [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3 (деплой) +- [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2 (код коннектора) +- [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3 (деплой, боевые причины тишины) - [[personal/tech/balda-go-fx-pitfalls]] — fx-питфоллы + дельта-апплаер - [[personal/plans/balda-mm-endtoend-plan]] — сквозной путь +- [[personal/tech/hermes-secret-redaction-bypass]] — редакция секретов diff --git a/personal/projects/balda/balda-mattermost-connector.md b/personal/projects/balda/balda-mattermost-connector.md index 2799e3c3..cdfcc6f1 100644 --- a/personal/projects/balda/balda-mattermost-connector.md +++ b/personal/projects/balda/balda-mattermost-connector.md @@ -1,6 +1,10 @@ # Balda — Mattermost коннектор (архитектура) -> Создано 2026-09-26. Обновлено 2026-09-26 (вечер). Статус: **✅ ЗАКРЫТА — ВСЕ QUALITY GATES ПРОЙДЕНЫ**. +> Создано 2026-09-26. Обновлено 2026-09-26 (сессия PR-подготовки). +> Статус: **✅ ЗАКРЫТА — ВСЕ QUALITY GATES ПРОЙДЕНЫ** (код `004fda2f`). +> 🟡 **Сессия PR-подготовки:** добавлены тесты транспорта, документация, +> починены 2 дефекта в коде. Остаток — линтер + коммит. Детали — +> [[personal/plans/balda-mm-pr-plan]]. > Часть проекта [[personal/projects/balda/balda-mm-delegation-plan]] (Фаза 2). > Требование Alex: поддержка **всех** функций Балды, по новым API, production-качество. > @@ -358,6 +362,110 @@ fx.Provide(NewIngress), | `TestValidateCommandRunsValidationWithMattermostTransport` | `cmd/balda/command_test.go` | **реальный** `loadBaldaCommandConfig(true)` + `validateBaldaApplication(prepared)` — весь fx-граф с включённым MM | | — (встроено в существующие) | — | регрессионная проверка `deliveryfmt` | +### 🔴 Тесты транспорта и хендлера (2026-09-26, сессия PR-подготовки) + +Шесть новых файлов, ~90 тестов, все зелёные включая `-race`. + +| Файл | Кол-во | Что покрывает | +|---|---|---| +| `channel/mattermost/locator_codec_test.go` | ~16 | локаторы channel/thread/DM; **изоляция thread↔channel сессий**; `LocatorFromAddressKey`; `ClassifyLocatorScope`; `UserID`/`ParseUserID`; отказ чужим транспортам | +| `channel/mattermost/inbound_test.go` | ~17 | `NormalizeInbound` → shared-контракт; `Typing:false`; `LocatorForPost` (канал/тред/DM); фильтры; `ParseCommand`; whitelist (`goalkeeper` есть, `goal` нет); `SupportedCommands` возвращает копию | +| `channel/mattermost/adapter_test.go` | ~16 | все `Deliver`-операции; plain-fallback **только** на 400; draft-as-post; typing = no-op **без сети**; thread `root_id`; 5xx **не** ретраится | +| `channel/mattermost/client_test.go` | ~13 | ws/wss derivation; `Authorization: Bearer`; `APIError`; `isContentRejectedError`; контекст; 2xx без `id` = ошибка | +| `channel/mattermost/ingress_test.go` | ~20 | 🔴 **регрессия `bad_seq`**; монотонность/уникальность `seq` под 16 горутинами; все фильтры; `recover()`; семафор; `Start` отказ без `bot_user_id` | +| `handlersfx/mattermost_ingress_test.go` | ~18 | публикация всех команд; 🔴 **одиночный префикс principal**; `/start` до авторизации; отказ чужим без публикации; nil-safety | + +**Метод (для будущих транспортов):** + +- **REST** — `httptest.NewServer` + fake-handler на `POST /api/v4/posts`, запоминающий + запросы и с инъецируемым `respond(attempt, req) (status, body)`. Так проверяются + fallback-сценарии (первый запрос → 400, второй → 201). +- **WebSocket** — реальный `websocket.Upgrader` в `httptest` + `websocket.DefaultDialer`. + Единственный способ проверить, что `writeEvent` всегда шлёт `seq`. +- **Хендлер** — тестируется **структура напрямую** (`&mattermostInboundHandler{...}`), + без fx: так проверяются авторизация, префикс principal и nil-safety в изоляции. +- `fake...OwnerKVStore` + `recording...CommandIngress` — по образцу `zulip_ingress_test.go`. + +### 🔴 Дефект P1.1 — двойной префикс `principal`: починен в КОДЕ + +**Симптом (прод):** `/start owner=` в DM отвечает успехом, но `@balda` даёт +`settlement_stage="unauthorized"` навсегда. + +**Корень:** `auth.MattermostSubject(userID)` добавляет префикс +(`fmt.Sprintf("%s:%s", ChannelMattermost, ...)`), а `actors/command/start/handler.go:420` +вызывал её поверх `p.Principal`, который **уже** приходит префиксованным от +`mattermost_ingress.go:147` (`Principal: auth.MattermostSubject(cmd.SenderID)`). +Результат в БД — `mattermost:mattermost:`, а `GetUserByBinding` сравнивает **точно** → +совпадений нет → `unauthorized`. Плюс мусор в `display_name` и `created_by` channel-токенов. + +**Прошлый «фикс» был в данных** (прямой `UPDATE` в `state.db`) — и дефект **вернулся** при +следующей записи. 🔴 **Правило: дефект, найденный в данных, чинить в коде.** + +**Фикс (дельта `tools/deltas-mattermost-23-prefix-idempotent.json`):** хелпер сделан +**идемпотентным** — срезает уже существующий префикс: + +```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` убран локальный `TrimPrefix`-костыль: +```go +case transportMattermost: + return auth.MattermostSubject(trimmed) +``` + +📌 **Почему фикс в хелпере, а не в вызывающем коде:** `MattermostSubject` вызывается из +трёх мест (`userSubject`, `mattermost_ingress.go:147`, `authorizeInbound`). Идемпотентность +закрывает **все** пути сразу и защищает от будущих вызовов поверх готового subject'а. +**Остальные вызовы были корректны** — дефект ровно один, но источник общий. + +**Регресс-тесты:** `TestMattermostSubjectIsIdempotent` (3 случая: raw, subject, padded) и +`TestMattermostHandlerSendsChannelQualifiedPrincipalExactlyOnce` (через публикуемую команду). + +### 🔴 Дефект P1.6 — `ProgressPolicy.Typing` расходился между путями + +`handlersfx/mattermost_ingress.go` в `HandleCommand` ставил +`ProgressPolicy{Typing: true, PlanUpdates: true}`, тогда как inbound-путь +(`inbound.go:135`) и `Adapter.SendTyping` (осознанный no-op — у ботов MM нет typing API) +говорили `false`. Мёртвый код: `SendProgress` при `Typing:true` вызывал `SendTyping`, +который всегда возвращал `nil`, и на каждом ходу писался бы warn только при ошибке. + +**Фикс** (дельта `tools/deltas-mattermost-24-typing.json`): `Typing: false` — command-путь +приведён к inbound-пути. **Оба пути теперь согласованы, и тест это фиксирует.** + +📌 **Урок:** при копировании Zulip-паттерна проверять каждую фичу на применимость к +провайдеру. Pan-путь и turn-путь обязаны давать **одинаковую** `ProgressPolicy` для одного +транспорта — расхождение невидимо компилятору. + +### 🔴 Второй урок: не доверять «проверил — зелёно» без проверки в изоляции + +Три моих первых теста падали, и **все три — моя ошибка, не дефект кода**: + +| Тест | Что я предположил | Реальность | +|---|---|---| +| `TestLocatorForPostScopesReplyToThreadRoot` | тред-сессия == сессия root-поста | Тред **отдельная** сессия от канала (`mm-t-...` vs `mm-c-...`) — это **правильный** дизайн, тест переписан на проверку «два ответа в треде → одна сессия» | +| `TestNewIngressFallsBackToClientUserID` | `NewIngress` подставляет bot_user_id из клиента | Не подставляет. Гард живёт в `onStart` («refuses to start without bot user id») — тест переписан на реальный контракт | +| `TestMattermostHandlerRejectsUnauthorizedCommand` | отказ возвращает `nil` | Возвращает `runtime is unavailable` (нет dispatcher для доставки отказа). **Главное — команда НЕ публикуется**, это и проверяется | + +📌 **Дыра в дизайне, найденная тестом:** root-пост треда (`RootID=""`) → сессия канала +`mm-c-`, а ответ в этом треде (`RootID=post-1`) → `mm-t-`. +То есть тред — **отдельная** беседа от канала. Это соответствует комментарию в +`locator_codec.go` («the locator key must stay stable for a whole thread»), но означает: +**бот, отвечающий в тред, ведёт отдельную от канала сессию.** Зафиксировано тестом +`TestLocatorForPostScopesReplyToThreadRoot` (два ответа в треде → одна сессия). + +⚠️ **Метод:** когда тест падает — сначала перечитать код и понять, чей это контракт. +Из трёх падений **ноль** оказались дефектами кода; все — неверные предположения теста. + + 🔴 **Ключевой урок:** `go build ./...` = exit 0 **не значит**, что транспорт рабочий. fx-граф проверяется только `validateBaldaApplication` (или `fx.ValidateApp`). Компиляция не видит ни `fx.In`-несовместимость, ни отсутствующие провайдеры. @@ -537,6 +645,10 @@ balda_mattermost_bot_username | `tools/deltas-mattermost-11.json` | удаление неиспользуемого импорта `sync` | | `tools/deltas-mattermost-12.json` | `unlambda`: голое `mattermost.NewAdapter` | | `tools/deltas-mattermost-13.json` | удаление неиспользуемого импорта `zerolog` из `module.go` | +| `tools/deltas-mattermost-23-prefix-idempotent.json` | 🔴 **P1.1:** `MattermostSubject` идемпотентна (срезает существующий префикс) + убран `TrimPrefix`-костыль из `userSubject` | +| `tools/deltas-mattermost-24-typing.json` | 🔴 **P1.6:** `ProgressPolicy.Typing` → `false` в command-пути (`handlersfx/mattermost_ingress.go`) | +| `tools/deltas-mattermost-25-docs-balda.json` | `docs/balda.md`: Mattermost в runtime stack, typing-нюанс, ссылка на гайд | +| `tools/deltas-mattermost-26-docs-readme.json` | `README.md`: Mattermost в 4 списках + `balda.mattermost.*` + ссылка на гайд | | `tools/.deltas-state.json` | состояние (sha256 применённых якорей) | 📌 **Дельты 9-13 — это один каскад линтера.** Удаление throttle-механизма тянуло diff --git a/personal/tech/balda-go-fx-pitfalls.md b/personal/tech/balda-go-fx-pitfalls.md index 87c7cbb6..134b8ca6 100644 --- a/personal/tech/balda-go-fx-pitfalls.md +++ b/personal/tech/balda-go-fx-pitfalls.md @@ -68,6 +68,41 @@ case transportMattermost: 📌 **Правило: дефект, найденный в данных, искать и в коде.** Правка данных без правки кода = дефект вернётся при следующей записи. Проверять **оба слоя** и фиксить источник. +### ✅ ИСПРАВЛЕНО 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`-подобные билдеры, вызванные поверх @@ -79,6 +114,70 @@ case transportMattermost: --- +## 🔴 Прогресс-политика должна совпадать на 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 занят чужим аккаунтом Ситуация: владельцем привязан **не тот** аккаунт, а нужно привязать правильный.