---
phase: 01-matrix-qa-polish
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - adapter/matrix/store.py
  - adapter/matrix/handlers/auth.py
  - adapter/matrix/room_router.py
autonomous: true
requirements: []

must_haves:
  truths:
    - "Bot creates a Space named 'Lambda - {display_name}' on first invite"
    - "Bot creates 'Chat 1' room inside that Space"
    - "Bot invites user to both Space and chat room"
    - "space_id is stored in user_meta for future lookups"
    - "Repeated invite does not create a second Space (idempotent)"
    - "chat_id uses next_chat_id, not hardcoded C1"
  artifacts:
    - path: "adapter/matrix/store.py"
      provides: "pending_confirm helpers + PENDING_CONFIRM_PREFIX"
      contains: "PENDING_CONFIRM_PREFIX"
    - path: "adapter/matrix/handlers/auth.py"
      provides: "Space+rooms invite flow"
      contains: "space=True"
    - path: "adapter/matrix/room_router.py"
      provides: "space-aware resolve_chat_id"
  key_links:
    - from: "adapter/matrix/handlers/auth.py"
      to: "adapter/matrix/store.py"
      via: "set_user_meta with space_id"
      pattern: "set_user_meta.*space_id"
    - from: "adapter/matrix/handlers/auth.py"
      to: "adapter/matrix/store.py"
      via: "next_chat_id for dynamic C-number"
      pattern: "next_chat_id"
---

<objective>
Rewrite the Matrix invite flow from DM-first to Space+rooms architecture, and add pending_confirm store helpers.

Purpose: Per D-01/D-02, the bot must create a Space per user on first invite, with a "Chat 1" room inside it. The old DM join + hardcoded C1 flow must be fully replaced. Additionally, pending_confirm helpers are added to store.py now (used by Plan 03) to avoid file conflicts.

Output: Working handle_invite that creates Space + chat room, stores space_id in user_meta, uses next_chat_id. Store has pending_confirm helpers.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-matrix-qa-polish/01-CONTEXT.md
@.planning/phases/01-matrix-qa-polish/01-RESEARCH.md

@adapter/matrix/store.py
@adapter/matrix/handlers/auth.py
@adapter/matrix/room_router.py
@core/protocol.py

<interfaces>
<!-- From adapter/matrix/store.py — current helpers the executor must preserve: -->

```python
ROOM_META_PREFIX = "matrix_room:"
USER_META_PREFIX = "matrix_user:"
ROOM_STATE_PREFIX = "matrix_state:"
SKILLS_MSG_PREFIX = "matrix_skills_msg:"

async def get_room_meta(store: StateStore, room_id: str) -> dict | None
async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None
async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None
async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None
async def get_room_state(store: StateStore, room_id: str) -> str
async def set_room_state(store: StateStore, room_id: str, state: str) -> None
async def get_skills_message_id(store: StateStore, room_id: str) -> str | None
async def set_skills_message_id(store: StateStore, room_id: str, event_id: str) -> None
async def next_chat_id(store: StateStore, matrix_user_id: str) -> str
```

<!-- From core/protocol.py — types used but NOT modified: -->

```python
@dataclass
class OutgoingMessage:
    chat_id: str
    text: str
    parse_mode: str = "plain"
    attachments: list[Attachment] = field(default_factory=list)
    reply_to: str | None = None
```

<!-- From nio.responses — error types for isinstance checks: -->

```python
from nio.responses import RoomCreateError, RoomPutStateError, RoomInviteError
# RoomCreateError has .status_code, no .room_id
# RoomPutStateError has .status_code
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add pending_confirm helpers to store.py</name>
  <files>adapter/matrix/store.py</files>
  <read_first>adapter/matrix/store.py</read_first>
  <action>
Add three new helper functions and one constant to `adapter/matrix/store.py`, AFTER the existing `next_chat_id` function. Keep ALL existing code unchanged.

Add this constant after line 8 (after `SKILLS_MSG_PREFIX`):

```python
PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"
```

Add these three functions at the end of the file:

```python
async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None:
    return await store.get(f"{PENDING_CONFIRM_PREFIX}{room_id}")


async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None:
    await store.set(f"{PENDING_CONFIRM_PREFIX}{room_id}", meta)


async def clear_pending_confirm(store: StateStore, room_id: str) -> None:
    await store.delete(f"{PENDING_CONFIRM_PREFIX}{room_id}")
```

Note: `store.delete` is already available on `StateStore` (both `InMemoryStore` and `SQLiteStore` implement it). Verify by checking `core/store.py` — if `delete` is not present, use `store.set(key, None)` as equivalent.

Per D-08: pending_confirm is keyed by room_id (not user_id+room_id) because in Space model each room belongs to one user.
  </action>
  <verify>
    <automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.store import get_pending_confirm, set_pending_confirm, clear_pending_confirm, PENDING_CONFIRM_PREFIX; print('OK')"</automated>
  </verify>
  <acceptance_criteria>
- `adapter/matrix/store.py` contains the string `PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"`
- `adapter/matrix/store.py` contains function `async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None:`
- `adapter/matrix/store.py` contains function `async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None:`
- `adapter/matrix/store.py` contains function `async def clear_pending_confirm(store: StateStore, room_id: str) -> None:`
- All existing functions (`get_room_meta`, `set_room_meta`, `get_user_meta`, `set_user_meta`, `get_room_state`, `set_room_state`, `get_skills_message_id`, `set_skills_message_id`, `next_chat_id`) still exist unchanged
- `pytest tests/adapter/matrix/test_store.py -x -q` passes (all existing store tests green)
  </acceptance_criteria>
  <done>pending_confirm helpers importable and existing store tests pass</done>
</task>

<task type="auto">
  <name>Task 2: Rewrite handle_invite for Space+rooms (per D-01, D-02)</name>
  <files>adapter/matrix/handlers/auth.py</files>
  <read_first>adapter/matrix/handlers/auth.py, adapter/matrix/store.py, core/protocol.py</read_first>
  <action>
Completely rewrite `adapter/matrix/handlers/auth.py`. The new `handle_invite` must:

1. **Idempotency check on user_meta (not room_meta):** Check `get_user_meta(store, matrix_user_id)`. If it already has a `space_id`, return early (do nothing). This replaces the old `get_room_meta(store, room.room_id)` check. Per Pitfall 5 from RESEARCH.md.

2. **Create Space:** Call `await client.room_create(name=f"Lambda -- {display_name}", space=True, visibility="private")`. Check `isinstance(resp, RoomCreateError)` — if error, log and return early.

3. **Create first chat room:** Call `await client.room_create(name="Chat 1", visibility="private", is_direct=False)`. Check `isinstance(resp, RoomCreateError)`.

4. **Add room to Space:** Call `await client.room_put_state(room_id=space_id, event_type="m.space.child", content={"via": [homeserver]}, state_key=chat_room_id)`. Extract `homeserver` as `matrix_user_id.split(":")[-1]`.

5. **Invite user to both:** `await client.room_invite(space_id, matrix_user_id)` and `await client.room_invite(chat_room_id, matrix_user_id)`.

6. **Use next_chat_id:** Call `chat_id = await next_chat_id(store, matrix_user_id)` to get "C1" (not hardcoded). Per D-05 and Pitfall 6 from RESEARCH.md.

7. **Store user_meta:** `await set_user_meta(store, matrix_user_id, {"space_id": space_id, "next_chat_index": 2})`. Note: next_chat_id already incremented to 2, so store will already have next_chat_index=2 after the call. Just ensure space_id is stored in user_meta.

8. **Store room_meta:** `await set_room_meta(store, chat_room_id, {"room_type": "chat", "chat_id": chat_id, "display_name": "Chat 1", "matrix_user_id": matrix_user_id, "space_id": space_id})`.

9. **Auth confirm:** Keep `await auth_mgr.confirm(matrix_user_id)`.

10. **Platform get_or_create_user:** Keep existing call.

11. **Welcome message:** Send to the CHAT ROOM (not the invite room). Text:
```
"Привет, {display_name}! Пиши -- я здесь.\n\nКоманды: !new . !chats . !rename . !archive . !skills . !soul . !safety . !settings"
```

12. **Also join the original invite room:** Keep `await client.join(room.room_id)` so the bot accepts the DM invite (otherwise nio may not process events from this user). Put this BEFORE Space creation.

Complete replacement for `adapter/matrix/handlers/auth.py`:

```python
from __future__ import annotations

import structlog
from typing import Any

from nio.responses import RoomCreateError

from adapter.matrix.store import get_user_meta, set_user_meta, set_room_meta, next_chat_id

logger = structlog.get_logger(__name__)


async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr) -> None:
    matrix_user_id = getattr(event, "sender", "")
    display_name = getattr(room, "display_name", None) or matrix_user_id

    # Idempotency: if user already has a Space, skip
    existing = await get_user_meta(store, matrix_user_id)
    if existing and existing.get("space_id"):
        return

    # Accept the invite room (so nio tracks this user)
    await client.join(room.room_id)

    # Register user on platform
    user = await platform.get_or_create_user(
        external_id=matrix_user_id,
        platform="matrix",
        display_name=display_name,
    )
    await auth_mgr.confirm(matrix_user_id)

    homeserver = matrix_user_id.split(":")[-1]

    # 1. Create Space
    space_resp = await client.room_create(
        name=f"Lambda \u2014 {display_name}",
        space=True,
        visibility="private",
    )
    if isinstance(space_resp, RoomCreateError):
        logger.error("space creation failed", user=matrix_user_id, error=getattr(space_resp, "status_code", None))
        return
    space_id = space_resp.room_id

    # 2. Create first chat room
    chat_resp = await client.room_create(
        name="\u0427\u0430\u0442 1",
        visibility="private",
        is_direct=False,
    )
    if isinstance(chat_resp, RoomCreateError):
        logger.error("chat room creation failed", user=matrix_user_id, error=getattr(chat_resp, "status_code", None))
        return
    chat_room_id = chat_resp.room_id

    # 3. Link chat room into Space
    await client.room_put_state(
        room_id=space_id,
        event_type="m.space.child",
        content={"via": [homeserver]},
        state_key=chat_room_id,
    )

    # 4. Invite user
    await client.room_invite(space_id, matrix_user_id)
    await client.room_invite(chat_room_id, matrix_user_id)

    # 5. Store metadata
    chat_id = await next_chat_id(store, matrix_user_id)  # Returns "C1", increments to 2

    # Update user_meta to include space_id (next_chat_id already set next_chat_index)
    user_meta = await get_user_meta(store, matrix_user_id) or {}
    user_meta["space_id"] = space_id
    await set_user_meta(store, matrix_user_id, user_meta)

    await set_room_meta(store, chat_room_id, {
        "room_type": "chat",
        "chat_id": chat_id,
        "display_name": "\u0427\u0430\u0442 1",
        "matrix_user_id": matrix_user_id,
        "space_id": space_id,
    })

    # 6. Welcome message in chat room
    welcome = (
        f"\u041f\u0440\u0438\u0432\u0435\u0442, {user.display_name or matrix_user_id}! \u041f\u0438\u0448\u0438 \u2014 \u044f \u0437\u0434\u0435\u0441\u044c.\n\n"
        "\u041a\u043e\u043c\u0430\u043d\u0434\u044b: !new \u00b7 !chats \u00b7 !rename \u00b7 !archive \u00b7 !skills \u00b7 !soul \u00b7 !safety \u00b7 !settings"
    )
    await client.room_send(chat_room_id, "m.room.message", {"msgtype": "m.text", "body": welcome})
```

IMPORTANT: Use the actual Cyrillic characters in strings, not unicode escapes. The unicode escapes above are just for plan encoding safety. The actual file must have readable Russian text: "Чат 1", "Привет, ...", "Команды: ..." etc.
  </action>
  <verify>
    <automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.handlers.auth import handle_invite; print('OK')"</automated>
  </verify>
  <acceptance_criteria>
- `adapter/matrix/handlers/auth.py` does NOT contain the string `"chat_id": "C1"` (hardcode removed)
- `adapter/matrix/handlers/auth.py` contains the string `space=True`
- `adapter/matrix/handlers/auth.py` contains the string `room_put_state`
- `adapter/matrix/handlers/auth.py` contains the string `next_chat_id`
- `adapter/matrix/handlers/auth.py` contains the string `get_user_meta`
- `adapter/matrix/handlers/auth.py` imports from `nio.responses` (specifically `RoomCreateError`)
- `adapter/matrix/handlers/auth.py` contains `room_invite` (invites user to Space and chat room)
- `adapter/matrix/handlers/auth.py` contains `m.space.child` string
  </acceptance_criteria>
  <done>handle_invite creates Space + chat room, stores space_id, uses next_chat_id, is idempotent on user_meta</done>
</task>

<task type="auto">
  <name>Task 3: Update room_router.py for space-aware resolve</name>
  <files>adapter/matrix/room_router.py</files>
  <read_first>adapter/matrix/room_router.py, adapter/matrix/store.py</read_first>
  <action>
The current `resolve_chat_id` in `adapter/matrix/room_router.py` auto-creates room_meta with a new chat_id if none exists. This is problematic in the Space model because rooms should only be created through `handle_invite` or `!new`. Update the fallback behavior:

Replace the entire `adapter/matrix/room_router.py` with:

```python
from __future__ import annotations

import structlog

from adapter.matrix.store import get_room_meta
from core.store import StateStore

logger = structlog.get_logger(__name__)


async def resolve_chat_id(store: StateStore, room_id: str, matrix_user_id: str) -> str:
    meta = await get_room_meta(store, room_id)
    if meta and meta.get("chat_id"):
        return meta["chat_id"]

    # Room not registered — this can happen if the bot receives a message
    # in a room it didn't create (e.g., a DM). Return a fallback chat_id
    # based on room_id to avoid crashing, but don't auto-register.
    logger.warning("unregistered_room", room_id=room_id, user=matrix_user_id)
    return f"unregistered:{room_id}"
```

Key changes:
- Remove `next_chat_id` and `set_room_meta` imports (no longer auto-creating)
- Remove auto-creation of room_meta for unknown rooms
- Return `f"unregistered:{room_id}"` as fallback so messages from unregistered rooms don't crash but are identifiable
- Add structlog warning for debugging
  </action>
  <verify>
    <automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.room_router import resolve_chat_id; print('OK')"</automated>
  </verify>
  <acceptance_criteria>
- `adapter/matrix/room_router.py` does NOT contain `next_chat_id`
- `adapter/matrix/room_router.py` does NOT contain `set_room_meta`
- `adapter/matrix/room_router.py` contains `unregistered:{room_id}` or `f"unregistered:{room_id}"`
- `adapter/matrix/room_router.py` contains `get_room_meta`
- `adapter/matrix/room_router.py` contains `logger.warning`
  </acceptance_criteria>
  <done>resolve_chat_id no longer auto-creates rooms, returns fallback for unregistered rooms</done>
</task>

</tasks>

<verification>
After all 3 tasks:
- `python -c "from adapter.matrix.handlers.auth import handle_invite; from adapter.matrix.store import get_pending_confirm, set_pending_confirm, clear_pending_confirm; from adapter.matrix.room_router import resolve_chat_id; print('ALL IMPORTS OK')"`
- `pytest tests/adapter/matrix/test_store.py -x -q` passes (existing store tests still green)
</verification>

<success_criteria>
- handle_invite creates Space (space=True) + chat room + room_put_state link
- No hardcoded "C1" in auth.py
- pending_confirm helpers available in store.py
- room_router doesn't auto-create rooms
- Existing store tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md`
</output>
