docs(phase-01): research Matrix QA & Polish — Space+rooms, !yes/!no, test gaps

This commit is contained in:
Mikhail Putilovskij 2026-04-02 18:08:12 +03:00
parent 9cf9f70d06
commit be8bc911e0

View file

@ -0,0 +1,528 @@
# Phase 1: Matrix QA & Polish — Research
**Researched:** 2026-04-02
**Domain:** matrix-nio AsyncClient — Space+rooms architecture, OutgoingUI text rendering, !yes/!no confirmation flow
**Confidence:** HIGH (all critical APIs verified against the installed library)
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Space+rooms — единственная поддерживаемая модель. DM-first убрать.
- **D-02:** При первом invite бот создаёт Space `Lambda — {display_name}`, внутри — первую комнату `Чат 1`. Приглашает пользователя.
- **D-03:** `!new [name]` создаёт новую комнату внутри Space пользователя, приглашает его туда.
- **D-04:** `!archive` выводит комнату из Space (не удаляет).
- **D-05:** Маппинг `room_id → space_id` + `room_id → chat_id` хранится в SQLite через `adapter/matrix/store.py`.
- **D-06:** Реакции (`👍`/`❌`) — убрать полностью. `OutgoingUI` с кнопками рендерится как текст + `!yes` / `!no`.
- **D-07:** Когда агент запрашивает подтверждение, бот пишет текстовое сообщение с описанием и подсказкой: `Ответьте !yes для подтверждения или !no для отмены.`
- **D-08:** `!yes` / `!no` работают в текущей комнате. Состояние ожидания подтверждения хранится per (user_id, room_id).
- **D-09:** Все команды работают из любой комнаты Space — нет выделенной комнаты «Настройки».
- **D-10:** Команды: `!new [name]`, `!chats`, `!rename <name>`, `!archive`, `!skills`, `!soul`, `!safety`, `!settings`, `!yes`, `!no`.
- **D-11:** `!start` — не нужен, онбординг через invite flow.
- **D-12:** `!settings` — read-only дашборд: одно сообщение со статусом всего (скиллы, soul, safety, активные чаты). Ничего не меняет.
- **D-13:** Изменения через субкоманды: `!skills`, `!skill on/off <name>`, `!soul`, `!soul name/style/priority/reset <value>`, `!safety`, `!safety on/off <action>`.
- **D-14:** Каждая команда без аргументов (`!skills`, `!soul`, `!safety`) выводит текущее состояние + подсказку как менять.
### Claude's Discretion
- Формат текста в Matrix (plain text vs markdown) — на усмотрение, Matrix клиенты рендерят markdown
- Структура invite-сообщения в новом Space — на усмотрение, главное: приветствие + список команд
- Обработка ошибок matrix-nio (room_create fail, join fail) — логировать + ответить пользователю
### Deferred Ideas (OUT OF SCOPE)
- Комната «Настройки» как отдельная закреплённая комната — решили не делать в Phase 1
- E2EE / python-olm — инфраструктурный трек, вне scope
- Space discovery (бот находит существующий Space при повторном invite) — Phase 2+
- Attachment handling (m.file, m.image, m.audio) — Phase 2+
</user_constraints>
---
## Summary
Phase 1 переписывает Matrix адаптер с DM-first на Space+rooms модель, убирает реакции в пользу `!yes`/`!no`, и реализует все команды управления. Большая часть бизнес-логики уже работает через `core/handlers/` и `adapter/matrix/handlers/settings.py`. Главная работа — в трёх точках: `handle_invite` (создание Space + двух комнат), `make_handle_new_chat` (добавление комнаты в Space), и `send_outgoing` (убрать реакции, добавить pending-state для `!yes`/`!no`).
Текущее состояние: 97 тестов зелёные. Для "96+ зелёных" после рефакторинга нужно обновить 3 существующих теста (они проверяют DM-поведение и реакции) и добавить ~12 новых тестов на Space-сценарии. Итого целевой range — 106110 тестов.
Критическая деталь: `AsyncClient.room_create` принимает `space=True` (булевый параметр, не `room_type="m.space"`) для создания Space. Добавление дочерней комнаты — через `room_put_state` на Space с event_type `m.space.child` и state_key = child room_id. Это проверено против установленной версии matrix-nio.
**Primary recommendation:** Реализовать в трёх независимых задачах Codex: (1) invite flow — Space+rooms creation, (2) send_outgoing — убрать реакции, добавить pending-confirm store, (3) обновить тесты под новое поведение.
---
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| matrix-nio | установлена (проверено: `space=True` параметр присутствует) | Matrix async клиент — room_create, room_put_state, room_invite, join | Единственный maintained async Python Matrix клиент |
| structlog | уже используется | Логирование | Уже в проекте |
| pytest-asyncio | уже используется | Async тесты | Уже в проекте |
**Версию matrix-nio не нужно менять.** Установленная версия поддерживает `space=True` в `room_create` и `room_put_state` для state events.
---
## Architecture Patterns
### Паттерн 1: Создание Space + первой комнаты (invite flow)
**Что:** При первом invite бот делает 5 последовательных API вызовов — создание Space, создание chat-комнаты, линковка child→Space, приглашение пользователя в обе, запись в store.
**Verified API** (из installed matrix-nio):
```python
# 1. Создать Space
space_resp = await client.room_create(
name=f"Lambda — {display_name}",
space=True, # <-- булевый флаг, не room_type
visibility="private",
is_direct=False,
)
# space_resp.room_id — строка
# 2. Создать первую chat-комнату
chat_resp = await client.room_create(
name="Чат 1",
visibility="private",
is_direct=False,
)
# chat_resp.room_id — строка
# 3. Добавить комнату в Space как child
# state_key = room_id дочерней комнаты
await client.room_put_state(
room_id=space_resp.room_id,
event_type="m.space.child",
content={
"via": [homeserver_domain], # например "matrix.org"
},
state_key=chat_resp.room_id,
)
# 4. Пригласить пользователя в Space и в chat-комнату
await client.room_invite(space_resp.room_id, matrix_user_id)
await client.room_invite(chat_resp.room_id, matrix_user_id)
# 5. Записать в store
await set_user_meta(store, matrix_user_id, {
"space_id": space_resp.room_id,
"next_chat_index": 2, # C1 уже занят
})
await set_room_meta(store, chat_resp.room_id, {
"room_type": "chat",
"chat_id": "C1",
"display_name": "Чат 1",
"matrix_user_id": matrix_user_id,
"space_id": space_resp.room_id,
})
```
**Важный gotcha:** Бот сам не вступает в Space (join). Он создаёт Space как владелец, поэтому уже является членом. `join` нужен только для входящей DM-комнаты (invite в существующую комнату). В новом flow: бот создаёт комнаты сам, поэтому `join` для Space и chat-комнаты не нужен.
### Паттерн 2: Добавление новой комнаты (!new)
```python
async def handle_new_chat(...):
user_meta = await get_user_meta(store, event.user_id) or {}
space_id = user_meta.get("space_id")
if not space_id:
# Пользователь не прошёл invite flow — не должно случиться, но guard нужен
return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: Space не найден.")]
chat_id = await next_chat_id(store, event.user_id)
room_name = " ".join(event.args).strip() or f"Чат {chat_id}"
resp = await client.room_create(name=room_name, visibility="private", is_direct=False)
room_id = resp.room_id
homeserver = event.user_id.split(":")[1] # "@user:matrix.org" → "matrix.org"
await client.room_put_state(
room_id=space_id,
event_type="m.space.child",
content={"via": [homeserver]},
state_key=room_id,
)
await client.room_invite(room_id, event.user_id)
await set_room_meta(store, room_id, {
"room_type": "chat",
"chat_id": chat_id,
"display_name": room_name,
"matrix_user_id": event.user_id,
"space_id": space_id,
})
```
### Паттерн 3: Archive (!archive) — убрать из Space
```python
# Убрать child: поставить пустой content (или content без 'via')
# Matrix spec: отправить m.space.child с пустым {} или без 'via' удаляет связь
await client.room_put_state(
room_id=space_id,
event_type="m.space.child",
content={}, # пустой content = удалить child relationship
state_key=room_id, # room_id архивируемой комнаты
)
```
Confidence: MEDIUM — Matrix spec говорит что пустой content убирает child, но поведение Element может варьироваться. Альтернатива: оставить room_put_state с `{"via": []}` (пустой массив).
### Паттерн 4: OutgoingUI → текст + !yes/!no (без реакций)
**Что убрать:**
- `_button_action_to_reaction` в `bot.py` — удалить целиком
- Блок `for button in event.buttons: reaction = _button_action_to_reaction(...)` — удалить
- `ReactionEvent` callback (`on_reaction` + `client.add_event_callback`) — удалить
- `from_reaction` в converter — оставить (используется для skill-reactions), но skill-reaction инфраструктура тоже под вопросом (D-06 убирает реакции полностью)
**Что добавить в `send_outgoing` для `OutgoingUI`:**
```python
if isinstance(event, OutgoingUI):
lines = [event.text, ""]
for button in event.buttons:
lines.append(f"• {button.label}")
lines += ["", "Ответьте !yes для подтверждения или !no для отмены."]
body = "\n".join(lines)
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
# Сохранить pending state per (user_id, room_id)
await set_pending_confirm(store, user_id=???, room_id=room_id, action_id=???)
```
**Проблема:** `send_outgoing` сейчас не знает `user_id` — только `room_id`. Для сохранения pending state нужен либо рефакторинг сигнатуры, либо хранение pending по `room_id` (без user_id — достаточно, т.к. room_id уникален для конкретного пользователя в Space модели).
### Паттерн 5: Pending confirm state
```python
# Новые helpers в adapter/matrix/store.py
PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"
async def get_pending_confirm(store, room_id: str) -> dict | None:
return await store.get(f"{PENDING_CONFIRM_PREFIX}{room_id}")
async def set_pending_confirm(store, room_id: str, meta: dict) -> None:
await store.set(f"{PENDING_CONFIRM_PREFIX}{room_id}", meta)
async def clear_pending_confirm(store, room_id: str) -> None:
await store.delete(f"{PENDING_CONFIRM_PREFIX}{room_id}")
```
`!yes`/`!no` уже конвертируются в `IncomingCallback(action="confirm"/"cancel")` в `converter.py`. Нужно обновить `handle_confirm`/`handle_cancel` в `adapter/matrix/handlers/confirm.py` чтобы читать pending state и возвращать осмысленный ответ.
### Паттерн 6: Hardcoded "C1" bug fix
```python
# auth.py:27 — СЕЙЧАС (баг):
"chat_id": "C1"
# ДОЛЖНО БЫТЬ:
chat_id = await next_chat_id(store, matrix_user_id) # возвращает "C1" для первого пользователя
```
`next_chat_id` уже существует в `store.py` и правильно инкрементирует per-user. Нужно просто использовать его в `handle_invite` вместо хардкода.
### Рекомендуемая структура store после рефакторинга
Текущие ключи в store:
- `matrix_room:{room_id}``{room_type, chat_id, display_name, matrix_user_id}` — **добавить `space_id`**
- `matrix_user:{user_id}``{next_chat_index, ...}` — **добавить `space_id`**
- `matrix_state:{room_id}``{state}` — оставить как есть
- `matrix_skills_msg:{room_id}``{event_id}` — оставить (или убрать если реакции полностью уходят)
Новые ключи:
- `matrix_pending_confirm:{room_id}``{action_id, description, expires_at}` — для !yes/!no
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Space creation | Кастомный HTTP запрос к Matrix API | `AsyncClient.room_create(space=True)` | Встроено в matrix-nio, управляет session state |
| Adding child room to Space | Кастомный state event builder | `AsyncClient.room_put_state(room_id, "m.space.child", ...)` | Правильный Content-Type, auth headers автоматически |
| User invite | Прямой HTTP PUT | `AsyncClient.room_invite(room_id, user_id)` | Обрабатывает ошибки M_FORBIDDEN, already-joined |
| Error detection | Проверка статус-кодов | `isinstance(resp, RoomCreateError)` / `isinstance(resp, RoomPutStateError)` | matrix-nio возвращает типизированные error-объекты |
---
## Common Pitfalls
### Pitfall 1: `room_create(space=True)` vs `room_type="m.space"`
**What goes wrong:** Передача `room_type="m.space"` как отдельный параметр — работает, но `space=True` — это удобный shortcut в matrix-nio, который внутри устанавливает тот же `room_type`. Оба варианта корректны, но `space=True` проще читается.
**Проверено:** `room_create` signature в installed matrix-nio имеет `space: bool = False`. Нет отдельного `is_space` параметра.
**How to avoid:** Использовать `space=True`, не `room_type="m.space"`.
### Pitfall 2: `room_id` из RoomCreateResponse — не `getattr`
**What goes wrong:** Текущий код в `handlers/chat.py:55`: `room_id = getattr(response, "room_id", None)`. Это работает для RoomCreateResponse, но молча возвращает None если пришёл RoomCreateError (у которого нет `room_id`).
**How to avoid:**
```python
from nio.responses import RoomCreateError
resp = await client.room_create(...)
if isinstance(resp, RoomCreateError):
logger.error("room_create failed", status_code=resp.status_code)
return [OutgoingMessage(..., text="Не удалось создать комнату.")]
room_id = resp.room_id # прямой доступ, не getattr
```
### Pitfall 3: `m.space.child` — state_key это room_id дочерней комнаты, не пустая строка
**What goes wrong:** `room_put_state` по умолчанию `state_key=""`. Для `m.space.child` state_key ДОЛЖЕН быть room_id дочерней комнаты — иначе Space создастся некорректно.
**How to avoid:** Всегда передавать `state_key=child_room_id` явно.
### Pitfall 4: Бот должен быть в Space чтобы добавлять children
**What goes wrong:** Бот создаёт Space (становится владельцем), потом пытается сделать `room_put_state` на Space. Это работает т.к. создатель автоматически имеет power level 100. Но если бот потерял membership (kicked out), `room_put_state` вернёт `M_FORBIDDEN`.
**How to avoid:** Логировать ошибку и сообщать пользователю. Не ретраить молча.
### Pitfall 5: Дублирование invite flow (идемпотентность)
**What goes wrong:** Текущий `handle_invite` проверяет `get_room_meta(store, room.room_id)` чтобы не запускать flow дважды. После рефакторинга на Space+rooms нужно проверять `get_user_meta(store, matrix_user_id)` — потому что invite может прийти повторно в разные комнаты Space, а Space создаётся один раз per user.
**How to avoid:** Idempotency check переносится на уровень user_meta: `if user_meta.get("space_id"): return`.
### Pitfall 6: `skills_message` реакции — остаток от старого UX
**What goes wrong:** `adapter/matrix/reactions.py` и `build_skills_text` до сих пор рендерят "Реакции 1⃣-9⃣ переключают навыки." По D-06 реакции убраны полностью. `build_skills_text` нужно обновить чтобы убрать эту строку и заменить инструкцией `!skill on/off <name>`.
**How to avoid:** Обновить `build_skills_text` + тест `test_reactions.py::test_build_skills_text`.
### Pitfall 7: `on_reaction` callback остаётся зарегистрированным
**What goes wrong:** В `main()` есть `client.add_event_callback(bot.on_reaction, ReactionEvent)`. Если убрать реакции но оставить этот callback — matrix-nio будет продолжать обрабатывать реакции и вызывать `on_reaction`. Нужно удалить и callback-регистрацию, и импорт `ReactionEvent`.
---
## Gaps between Current Implementation and Target
| File | Current State | Target State | Action |
|------|--------------|-------------|--------|
| `adapter/matrix/handlers/auth.py` | DM join + hardcoded C1 | Space creation + C1 from next_chat_id | Переписать `handle_invite` |
| `adapter/matrix/handlers/chat.py` | room_create без Space | room_create + room_put_state в Space | Обновить `make_handle_new_chat` |
| `adapter/matrix/bot.py` | `on_reaction` + `_button_action_to_reaction` | Без реакций, pending-state для !yes/!no | Убрать reaction code; обновить `send_outgoing` |
| `adapter/matrix/store.py` | Нет `space_id`, нет pending_confirm | `space_id` в room_meta + user_meta; `pending_confirm` helpers | Добавить поля и helpers |
| `adapter/matrix/reactions.py` | `build_skills_text` упоминает реакции | `build_skills_text` без реакций, с `!skill on/off` | Обновить текст |
| `adapter/matrix/handlers/confirm.py` | Заглушка без state | Читает pending_confirm, даёт реальный ответ | Обновить handlers |
| `adapter/matrix/handlers/settings.py` | `handle_settings` — список команд | `handle_settings` — read-only дашборд (D-12) | Обновить до дашборда со статусом |
| `adapter/matrix/converter.py` | `from_reaction` используется для skill toggle | Skill toggle через реакции убирается | `from_reaction` можно оставить или удалить |
---
## Code Examples
### Создание Space + child room (verified API)
```python
# Source: matrix-nio installed version — inspect.signature(AsyncClient.room_create)
from nio.responses import RoomCreateError, RoomPutStateError
async def create_user_space(client, display_name: str, matrix_user_id: str, store):
homeserver = matrix_user_id.split(":")[-1] # "@user:matrix.org" → "matrix.org"
# Step 1: Create Space
space_resp = await client.room_create(
name=f"Lambda — {display_name}",
space=True,
visibility="private",
)
if isinstance(space_resp, RoomCreateError):
return None, None
space_id = space_resp.room_id
# Step 2: Create first chat room
chat_resp = await client.room_create(
name="Чат 1",
visibility="private",
is_direct=False,
)
if isinstance(chat_resp, RoomCreateError):
return space_id, None
chat_room_id = chat_resp.room_id
# Step 3: Link child room into Space (state_key = child's room_id)
await client.room_put_state(
room_id=space_id,
event_type="m.space.child",
content={"via": [homeserver]},
state_key=chat_room_id,
)
# Step 4: Invite user to Space and to chat room
await client.room_invite(space_id, matrix_user_id)
await client.room_invite(chat_room_id, matrix_user_id)
return space_id, chat_room_id
```
### send_outgoing для OutgoingUI (без реакций)
```python
if isinstance(event, OutgoingUI):
lines = [event.text]
if event.buttons:
lines.append("")
for btn in event.buttons:
lines.append(f"• {btn.label}")
lines.append("")
lines.append("Ответьте !yes для подтверждения или !no для отмены.")
body = "\n".join(lines)
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
```
### Проверка ошибок matrix-nio
```python
from nio.responses import RoomCreateError, RoomPutStateError, RoomInviteError
resp = await client.room_create(...)
if isinstance(resp, RoomCreateError):
logger.error("room_create failed", status_code=resp.status_code)
# resp не имеет room_id — безопасный ранний возврат
return [OutgoingMessage(chat_id=event.chat_id, text="Не удалось создать комнату.")]
room_id = resp.room_id # str, гарантированно присутствует
```
---
## Validation Architecture
nyquist_validation = true в config.json — раздел обязателен.
### Test Framework
| Property | Value |
|----------|-------|
| Framework | pytest + pytest-asyncio |
| Config file | pytest.ini или pyproject.toml (проверить наличие) |
| Quick run command | `pytest tests/adapter/matrix/ -q` |
| Full suite command | `pytest tests/ -q` |
| Current count | 97 passed |
### Существующие тесты Matrix, требующие обновления
Эти тесты написаны под DM/reaction-based поведение и сломаются после рефакторинга:
| Test | Текущее поведение | После рефакторинга | Действие |
|------|------------------|-------------------|---------|
| `test_dispatcher.py::test_invite_event_creates_dm_room_and_sends_welcome` | Проверяет `chat_id == "C1"` через hardcode, join DM | Должен проверять Space creation + chat room creation | Переписать |
| `test_dispatcher.py::test_new_chat_creates_real_matrix_room_when_client_available` | Проверяет `room_create` без Space | Должен проверять `room_create` + `room_put_state` | Обновить mock + assertions |
| `test_reactions.py::test_build_skills_text` | Ожидает "Реакции 1⃣-9⃣" в тексте | После удаления реакций эта строка исчезнет | Обновить assertion |
| `test_reactions.py::test_build_confirmation_text` | Проверяет `CONFIRM_REACTION` + "подтвердить" | Если `build_confirmation_text` обновится под D-07 | Обновить |
### Новые тесты, необходимые для покрытия Space+rooms
| ID | Behavior | Test Type | File | Command |
|----|----------|-----------|------|---------|
| MAT-01 | handle_invite создаёт Space + Чат 1, сохраняет space_id в user_meta | unit | `tests/adapter/matrix/test_invite_space.py` | `pytest tests/adapter/matrix/test_invite_space.py -x` |
| MAT-02 | handle_invite идемпотентен: повторный вызов не создаёт второй Space | unit | `tests/adapter/matrix/test_invite_space.py` | `pytest tests/adapter/matrix/test_invite_space.py -x` |
| MAT-03 | handle_invite использует next_chat_id, не хардкод "C1" | unit | `tests/adapter/matrix/test_invite_space.py` | `pytest tests/adapter/matrix/test_invite_space.py -x` |
| MAT-04 | make_handle_new_chat вызывает room_put_state с space_id из user_meta | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` |
| MAT-05 | make_handle_new_chat без space_id возвращает error message | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` |
| MAT-06 | send_outgoing для OutgoingUI рендерит текст + "!yes / !no", без реакций | unit | `tests/adapter/matrix/test_send_outgoing.py` | `pytest tests/adapter/matrix/test_send_outgoing.py -x` |
| MAT-07 | send_outgoing для OutgoingUI НЕ отправляет m.reaction event | unit | `tests/adapter/matrix/test_send_outgoing.py` | `pytest tests/adapter/matrix/test_send_outgoing.py -x` |
| MAT-08 | get/set/clear_pending_confirm roundtrip в store | unit | `tests/adapter/matrix/test_store.py` (extend) | `pytest tests/adapter/matrix/test_store.py -x` |
| MAT-09 | handle_confirm читает pending_confirm и возвращает описание действия | unit | `tests/adapter/matrix/test_confirm.py` | `pytest tests/adapter/matrix/test_confirm.py -x` |
| MAT-10 | handle_archive вызывает room_put_state с пустым content | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` |
| MAT-11 | !settings возвращает дашборд со статусом (не список команд) | unit | `tests/adapter/matrix/test_dispatcher.py` (extend) | `pytest tests/adapter/matrix/test_dispatcher.py -x` |
| MAT-12 | RoomCreateError обрабатывается корректно (нет crash, есть user message) | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` |
### Wave 0 Gaps (новые файлы)
- [ ] `tests/adapter/matrix/test_invite_space.py` — покрывает MAT-01, MAT-02, MAT-03
- [ ] `tests/adapter/matrix/test_chat_space.py` — покрывает MAT-04, MAT-05, MAT-10, MAT-12
- [ ] `tests/adapter/matrix/test_send_outgoing.py` — покрывает MAT-06, MAT-07
- [ ] `tests/adapter/matrix/test_confirm.py` — покрывает MAT-09
### Sampling Rate
- **Per task commit:** `pytest tests/adapter/matrix/ -q`
- **Per wave merge:** `pytest tests/ -q`
- **Phase gate:** All 97+ tests green (целевой диапазон 106110 после добавления новых)
### Численный ориентир для "96+ зелёных"
- Сейчас: 97 тестов, все зелёные
- После рефакторинга без добавления тестов: 4 теста сломаются (3 dispatcher + 1 reactions) → ~93 зелёных
- После обновления сломанных: 97 зелёных
- После добавления 12 новых: ~109 зелёных
- **Итого: требование "96+" выполнено с запасом**
---
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| matrix-nio | All Matrix API calls | ✓ | установлена, space=True присутствует | — |
| pytest + pytest-asyncio | Test suite | ✓ | работает (97 passed) | — |
| SQLite | SQLiteStore | ✓ | встроен в Python | — |
| Matrix homeserver | Manual QA только | не проверялось | — | Без homeserver — только unit тесты |
**Missing dependencies with no fallback:** Нет (homeserver нужен только для ручного QA, не для автотестов).
---
## Project Constraints (from CLAUDE.md)
| Directive | Impact on Phase |
|-----------|----------------|
| `core/protocol.py` — типы не менять | `IncomingCommand`, `OutgoingUI`, `UIButton` используем as-is |
| Все вызовы платформы через `platform/interface.py` | MockPlatformClient остаётся, SDK не трогать |
| Хотфиксы < 20 строк Claude Code напрямую | Небольшие правки реакций-в-текст могут идти напрямую |
| Реализацию делает Codex | Три задачи — три параллельных Codex запуска |
| Blueprint перед реализацией | Плану нужны blueprint-документы для каждой задачи |
| Порядок зависимостей: core/ → platform/ → adapters/ | Все изменения только в adapter/matrix/, core/ не трогаем |
---
## Open Questions
1. **Стоит ли полностью убирать `from_reaction` и `reactions.py`?**
- D-06 говорит "убрать реакции полностью"
- `reactions.py` содержит `build_confirmation_text` и `build_skills_text` — они нужны после рефакторинга
- Рекомендация: оставить `reactions.py`, удалить `CONFIRM_REACTION`/`CANCEL_REACTION`/`add_reaction`/`remove_reaction`, переименовать в `formatting.py` — но это необязательно для Phase 1.
2. **Нужен ли `m.space.parent` event в дочерних комнатах?**
- Matrix spec позволяет устанавливать `m.space.parent` в дочерней комнате, чтобы Element показывал ссылку "назад к Space"
- Не является обязательным — `m.space.child` в Space достаточно для включения комнаты в Space
- Рекомендация: не добавлять в Phase 1, отложить если понадобится.
3. **`via` в `m.space.child` — один сервер или несколько?**
- Для single-homeserver деплоя: `["homeserver_domain"]` достаточно
- Для федерации: нужны несколько серверов
- Рекомендация: парсить из `matrix_user_id.split(":")[-1]` — достаточно для текущего использования.
---
## Sources
### Primary (HIGH confidence)
- matrix-nio installed package — `AsyncClient.room_create`, `room_put_state`, `room_invite`, `join` — сигнатуры и docstrings проверены через `inspect.signature` и `help()`
- `nio.responses.RoomCreateResponse`, `RoomCreateError`, `RoomPutStateResponse`, `RoomPutStateError` — поля проверены через `inspect.getsource`
- Весь codebase прочитан напрямую
### Secondary (MEDIUM confidence)
- Matrix Spec v1.x — `m.space.child` event format (content `{"via": [...]}`, state_key = child room_id) — стандартное поведение, описано в Matrix spec
---
## Metadata
**Confidence breakdown:**
- matrix-nio API: HIGH — проверено против installed package через Python introspection
- Space creation pattern: HIGH — `space=True` параметр подтверждён в room_create signature
- `m.space.child` content format: MEDIUM — стандарт Matrix spec, не проверен против конкретного homeserver
- Archive via empty content: MEDIUM — Matrix spec behaviour, может зависеть от homeserver version
- Тест-план: HIGH — основан на прямом анализе существующих тестов
**Research date:** 2026-04-02
**Valid until:** 2026-05-02 (matrix-nio обновляется редко, Space API стабилен с Matrix v1.2)