# Channel Protocol Architecture

**Status:** Draft
**Authors:** Mosaic Core Team
**Last Updated:** 2026-03-22
**Covers:** M7-001 (IChannelAdapter interface), M7-002 (ChannelMessage protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix)

---

## Overview

The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels).

The protocol consists of two main contracts:

1. `IChannelAdapter` — the interface each channel driver must implement.
2. `ChannelMessage` — the canonical message format that flows through the system.

All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects.

---

## M7-001: IChannelAdapter Interface

```typescript
interface IChannelAdapter {
  /**
   * Stable, lowercase identifier for this channel (e.g. "matrix", "discord").
   * Used as a namespace key in registry lookups and log metadata.
   */
  readonly name: string;

  /**
   * Establish a connection to the external channel backend.
   * Called once at application startup. Must be idempotent (safe to call
   * when already connected).
   */
  connect(): Promise<void>;

  /**
   * Gracefully disconnect from the channel backend.
   * Must flush in-flight sends and release resources before resolving.
   */
  disconnect(): Promise<void>;

  /**
   * Return the current health of the adapter connection.
   * Used by the admin health endpoint and alerting.
   *
   * - "connected"    — fully operational
   * - "degraded"     — partial connectivity (e.g. read-only, rate-limited)
   * - "disconnected" — no connection to channel backend
   */
  health(): Promise<{ status: 'connected' | 'degraded' | 'disconnected' }>;

  /**
   * Register an inbound message handler.
   * The adapter calls `handler` for every message received from the channel.
   * Multiple calls replace the previous handler (last-write-wins).
   * The handler is async; the adapter must not deliver new messages until
   * the previous handler promise resolves (back-pressure).
   */
  onMessage(handler: (msg: ChannelMessage) => Promise<void>): void;

  /**
   * Send a ChannelMessage to the given channel/room/conversation.
   * `channelId` is the channel-native identifier (e.g. Matrix room ID,
   * Discord channel snowflake, Telegram chat ID).
   */
  sendMessage(channelId: string, msg: ChannelMessage): Promise<void>;

  /**
   * Map a channel-native user identifier to the Mosaic internal userId.
   * Returns null when no matching Mosaic account exists for the given
   * channelUserId (anonymous or unlinked user).
   */
  mapIdentity(channelUserId: string): Promise<string | null>;
}
```

### Adapter Registration

Adapters are registered with the `ChannelRegistry` service at startup. The registry calls `connect()` on each adapter and monitors `health()` on a configurable interval (default: 30 s).

```
ChannelRegistry
  └── register(adapter: IChannelAdapter): void
  └── getAdapter(name: string): IChannelAdapter | null
  └── listAdapters(): IChannelAdapter[]
  └── healthAll(): Promise<Record<string, AdapterHealth>>
```

---

## M7-002: ChannelMessage Protocol

### Canonical Message Format

```typescript
interface ChannelMessage {
  /**
   * Globally unique message ID.
   * Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
   * when sending. Channel-native IDs are stored in metadata.channelMessageId.
   */
  id: string;

  /**
   * Channel-native room/conversation/channel identifier.
   * The adapter populates this from the inbound message.
   * For outbound messages, the caller supplies the target channel.
   */
  channelId: string;

  /**
   * Channel-native identifier of the message sender.
   * For Mosaic-originated messages this is the Mosaic userId or agentId.
   */
  senderId: string;

  /** Sender classification. */
  senderType: 'user' | 'agent' | 'system';

  /**
   * Textual content of the message.
   * For non-text content types (image, file) this may be an empty string
   * or an alt-text description; the actual payload is in `attachments`.
   */
  content: string;

  /**
   * Hint for how `content` should be interpreted and rendered.
   * - "text"     — plain text, no special rendering
   * - "markdown" — CommonMark markdown
   * - "code"     — code block (use metadata.language for the language tag)
   * - "image"    — binary image; content is empty, see attachments
   * - "file"     — binary file; content is empty, see attachments
   */
  contentType: 'text' | 'markdown' | 'code' | 'image' | 'file';

  /**
   * Arbitrary key-value metadata for channel-specific extension fields.
   * Examples: { channelMessageId, language, reactionEmoji, channelType }.
   * Adapters should store channel-native IDs here so round-trip correlation
   * is possible without altering the canonical fields.
   */
  metadata: Record<string, unknown>;

  /**
   * Optional thread or reply-chain identifier.
   * For threaded channels (Matrix, Discord threads, Telegram topics) this
   * groups messages into a logical thread scoped to the same channelId.
   */
  threadId?: string;

  /**
   * The canonical message ID this message is a reply to.
   * Maps to channel-native reply/quote mechanisms in each adapter.
   */
  replyToId?: string;

  /**
   * Binary or URI-referenced attachments.
   * Each attachment carries its MIME type and a URL or base64 payload.
   */
  attachments?: ChannelAttachment[];

  /** Wall-clock timestamp when the message was sent/received. */
  timestamp: Date;
}

interface ChannelAttachment {
  /** Filename or identifier. */
  name: string;

  /** MIME type (e.g. "image/png", "application/pdf"). */
  mimeType: string;

  /**
   * URL pointing to the attachment, OR a `data:` URI with base64 payload.
   * Adapters that receive file uploads SHOULD store to object storage and
   * populate a stable URL here rather than embedding the raw bytes.
   */
  url: string;

  /** Size in bytes, if known. */
  sizeBytes?: number;
}
```

---

## Channel Translation Reference

The following sections document how each supported channel maps its native message format to and from `ChannelMessage`.

### Matrix

| ChannelMessage field | Matrix equivalent                                                                                                                 |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `id`                 | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`)                                                            |
| `channelId`          | Matrix room ID (`!roomid:homeserver`)                                                                                             |
| `senderId`           | Matrix user ID (`@user:homeserver`)                                                                                               |
| `senderType`         | Always `"user"` for inbound; `"agent"` or `"system"` for outbound                                                                 |
| `content`            | `event.content.body`                                                                                                              |
| `contentType`        | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
| `threadId`           | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread`                                                            |
| `replyToId`          | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']`                                             |
| `attachments`        | Populated from `url` in `m.image` / `m.file` events                                                                               |
| `timestamp`          | `new Date(event.origin_server_ts)`                                                                                                |
| `metadata`           | `{ channelMessageId, roomId, eventType, unsigned }`                                                                               |

**Outbound:** Adapter sends `m.room.message` with `msgtype = m.text` (or `m.notice` for system messages). Markdown content is sent with `format = org.matrix.custom.html` and a rendered HTML body.

---

### Discord

| ChannelMessage field | Discord equivalent                                                      |
| -------------------- | ----------------------------------------------------------------------- |
| `id`                 | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
| `channelId`          | Discord channel ID (snowflake string)                                   |
| `senderId`           | Discord user ID (snowflake)                                             |
| `senderType`         | `"user"` for human members; `"agent"` for bot messages                  |
| `content`            | `message.content`                                                       |
| `contentType`        | `"markdown"` (Discord uses a markdown-like syntax natively)             |
| `threadId`           | `message.thread.id` when the message is inside a thread channel         |
| `replyToId`          | Mosaic ID looked up from `message.referenced_message.id`                |
| `attachments`        | `message.attachments` mapped to `ChannelAttachment`                     |
| `timestamp`          | `new Date(message.timestamp)`                                           |
| `metadata`           | `{ channelMessageId, guildId, channelType, mentions, embeds }`          |

**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.

---

### Telegram

| ChannelMessage field | Telegram equivalent                                                                                           |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| `id`                 | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer)                                 |
| `channelId`          | Telegram `chat_id` (integer as string)                                                                        |
| `senderId`           | Telegram `from.id` (integer as string)                                                                        |
| `senderType`         | `"user"` for human senders; `"agent"` for bot-originated messages                                             |
| `content`            | `message.text` or `message.caption`                                                                           |
| `contentType`        | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
| `threadId`           | `message.message_thread_id` (for supergroup topics)                                                           |
| `replyToId`          | Mosaic ID looked up from `message.reply_to_message.message_id`                                                |
| `attachments`        | `photo`, `document`, `video` fields mapped to `ChannelAttachment`                                             |
| `timestamp`          | `new Date(message.date * 1000)`                                                                               |
| `metadata`           | `{ channelMessageId, chatType, fromUsername, forwardFrom }`                                                   |

**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.

---

### TUI (Terminal UI)

The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel.

| ChannelMessage field | TUI equivalent                                                     |
| -------------------- | ------------------------------------------------------------------ |
| `id`                 | Generated UUID (TUI has no native message IDs)                     |
| `channelId`          | `"tui:<conversationId>"` — the active conversation ID              |
| `senderId`           | Authenticated Mosaic `userId`                                      |
| `senderType`         | `"user"` for human input; `"agent"` for agent replies              |
| `content`            | Raw text from stdin / agent output                                 |
| `contentType`        | `"text"` for input; `"markdown"` for agent responses               |
| `threadId`           | Not used (TUI sessions are linear)                                 |
| `replyToId`          | Not used                                                           |
| `attachments`        | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
| `timestamp`          | `new Date()` at the moment of send                                 |
| `metadata`           | `{ conversationId, sessionId, ttyWidth, colorSupport }`            |

**Outbound:** The adapter writes rendered content to stdout. Markdown is rendered via a terminal markdown renderer (e.g. `marked-terminal`). Code blocks are syntax-highlighted when `metadata.colorSupport = true`.

---

### WebUI

The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`).

| ChannelMessage field | WebUI equivalent                                             |
| -------------------- | ------------------------------------------------------------ |
| `id`                 | Generated UUID; echoed back in the WebSocket event           |
| `channelId`          | `"webui:<conversationId>"`                                   |
| `senderId`           | Authenticated Mosaic `userId`                                |
| `senderType`         | `"user"` for browser input; `"agent"` for agent responses    |
| `content`            | Message text from the input field                            |
| `contentType`        | `"text"` or `"markdown"`                                     |
| `threadId`           | Not used (conversation model handles threading)              |
| `replyToId`          | Message ID the user replied to (UI reply affordance)         |
| `attachments`        | Files uploaded via the file picker; stored to object storage |
| `timestamp`          | `new Date()` at send, or server timestamp from event         |
| `metadata`           | `{ conversationId, sessionId, clientTimezone, userAgent }`   |

**Outbound:** Adapter emits a `chat:message` Socket.IO event. The WebUI React component receives it and appends to the conversation list. Markdown content is rendered client-side via the existing markdown renderer component.

---

## Identity Mapping

`mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts.

The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context).

```
channel_identities
  channel_name   TEXT       -- e.g. "matrix", "discord"
  channel_user_id TEXT      -- channel-native user identifier
  mosaic_user_id  TEXT      -- FK to users.id
  linked_at       TIMESTAMP
  PRIMARY KEY (channel_name, channel_user_id)
```

Identity linking flows (OAuth dance, deep-link verification token, etc.) are out of scope for this document and will be specified in a separate identity-linking protocol document.

---

## Error Handling Conventions

- `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s).
- `sendMessage()` must throw `ChannelSendError` on terminal failures (auth revoked, channel not found). Transient failures (rate limit, network blip) should be retried internally with exponential backoff before throwing.
- `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.

---

## Versioning

The `ChannelMessage` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.

Current version: **1.0.0**

---

## M7-003: Matrix Integration Design

### Homeserver Choice

Mosaic uses **Conduit** as the Matrix homeserver. Conduit is written in Rust, ships as a single binary, and has minimal operational overhead compared to Synapse or Dendrite. It supports the full Matrix Client-Server and Application Service APIs required by Mosaic.

Recommended deployment: Conduit runs as a Docker container alongside the Mosaic stack. A single Conduit instance is sufficient for most self-hosted deployments. Conduit's embedded RocksDB storage means no separate database is required for the homeserver itself.

### Appservice Registration

Mosaic registers with the Conduit homeserver as a Matrix **Application Service (appservice)**. This gives Mosaic the ability to:

- Create and control ghost users (virtual Matrix users representing Mosaic agents and provisioned accounts).
- Receive all events sent to rooms within the appservice's namespace without polling.
- Send events on behalf of ghost users without separate authentication.

Registration is done via a YAML registration file (`mosaic-appservice.yaml`) placed in Conduit's configuration directory:

```yaml
id: mosaic
url: http://gateway:3000/_matrix/appservice
as_token: <random-secret>
hs_token: <random-secret>
sender_localpart: mosaic-bot
namespaces:
  users:
    - exclusive: true
      regex: '@mosaic_.*:homeserver'
  rooms:
    - exclusive: false
      regex: '.*'
  aliases:
    - exclusive: true
      regex: '#mosaic-.*:homeserver'
```

The gateway exposes `/_matrix/appservice` endpoints to receive push events from Conduit. The `as_token` and `hs_token` are stored in Vault and injected at startup.

### Room ↔ Conversation Mapping

Each Mosaic conversation maps to a single Matrix room. The mapping is stored in the database:

```
conversation_matrix_rooms
  conversation_id  TEXT  -- FK to conversations.id
  room_id          TEXT  -- Matrix room ID (!roomid:homeserver)
  created_at       TIMESTAMP
  PRIMARY KEY (conversation_id)
```

Room creation is handled by the appservice on the first Matrix access to a conversation. Room names follow the pattern `Mosaic: <conversation title>`. Room topics contain the conversation ID for correlation.

When a conversation is deleted or archived in Mosaic, the corresponding Matrix room is tombstoned (m.room.tombstone event) and the room is left in a read-only state.

### Space ↔ Team Mapping

Each Mosaic team maps to a Matrix **Space**. Spaces are Matrix rooms with a special `m.space` type that can contain child rooms.

```
team_matrix_spaces
  team_id    TEXT  -- FK to teams.id
  space_id   TEXT  -- Matrix room ID of the Space
  created_at TIMESTAMP
  PRIMARY KEY (team_id)
```

When a conversation room is shared with a team, the appservice adds it to the team's Space via `m.space.child` state events. Removing the share removes the child relationship.

### Agent Ghost Users

Each Mosaic agent is represented in Matrix as an **appservice ghost user**:

- Matrix user ID format: `@mosaic_agent_<agentId>:homeserver`
- Display name: the agent's human-readable name (e.g. "Mosaic Assistant")
- Avatar: optional, configurable per agent

Ghost users are registered lazily — the appservice creates the ghost on first use. Ghost users are controlled exclusively by the appservice; they cannot log in via Matrix client credentials.

When an agent sends a message via the gateway, the Matrix adapter sends the event using `user_id` impersonation on the appservice's client endpoint, causing the message to appear as if sent by the ghost user.

### Power Levels

Power levels in each Mosaic-managed room are set as follows:

| Entity                                | Power Level    | Rationale                              |
| ------------------------------------- | -------------- | -------------------------------------- |
| Mosaic appservice bot (`@mosaic-bot`) | 100 (Admin)    | Room management and moderation         |
| Human Mosaic users                    | 50 (Moderator) | Can kick, redact, and invite           |
| Agent ghost users                     | 0 (Default)    | Message-only; cannot modify room state |

This arrangement ensures human users retain full control. An agent cannot modify room settings, kick members, or take administrative actions. Humans with moderator power can redact agent messages and intervene in ongoing conversations.

```
mermaid
graph TD
    A[Mosaic Admin] -->|invites| B[Human User]
    B -->|joins| C[Matrix Room / Conversation]
    D[Agent Ghost User] -->|sends messages to| C
    B -->|can redact/kick| D
    E[Mosaic Bot] -->|manages room state| C
    style A fill:#4a9eff
    style B fill:#4a9eff
    style D fill:#aaaaaa
    style E fill:#ff9944
```

---

## M7-004: Conversation Multiplexing

### Architecture Overview

A single Mosaic conversation can be accessed simultaneously from multiple surfaces: TUI, WebUI, and Matrix. The gateway is the **single source of truth** for all conversation state. Each surface is a thin client that renders gateway-owned data.

```
┌─────────────────────────────────────────────────────┐
│                    Gateway (NestJS)                  │
│                                                      │
│   ConversationService  ←→  MessageBus               │
│         │                      │                    │
│    [DB: PostgreSQL]     [Fanout: Valkey Pub/Sub]     │
│                                │                    │
│          ┌─────────────────────┼──────────────┐     │
│          │                     │              │     │
│   Socket.IO         Socket.IO      Matrix     │     │
│   (TUI adapter)    (WebUI adapter) (appservice)│    │
└──────────┼─────────────────────┼──────────────┘     │
           │                     │              │
       CLI/TUI              Browser          Matrix
                                             Client
```

### Real-Time Sync Flow

1. A message arrives on any surface (TUI keystroke, browser send, Matrix event).
2. The surface's adapter normalizes the message to `ChannelMessage` and delivers it to `ConversationService`.
3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`.
4. All active surfaces subscribed to that `conversationId` receive the fanout event and push it to their respective clients:
   - TUI adapter: writes rendered output to the connected terminal session.
   - WebUI adapter: emits a `chat:message` Socket.IO event to all browser sessions joined to that conversation.
   - Matrix adapter: sends an `m.room.message` event to the conversation's Matrix room.

This ensures that a message typed in the TUI appears in the browser and in Matrix within the same round-trip latency as the Valkey fanout (typically <10 ms on co-located infrastructure).

### Surface-to-Transport Mapping

| Surface | Transport to Gateway                       | Fanout Transport from Gateway |
| ------- | ------------------------------------------ | ----------------------------- |
| TUI     | HTTPS REST + SSE or WebSocket              | Socket.IO over stdio proxy    |
| WebUI   | Socket.IO (browser)                        | Socket.IO emit                |
| Matrix  | Matrix Client-Server API (appservice push) | Matrix `m.room.message` send  |

### Conflict Resolution

- **Messages**: Append-only. Messages are never edited in-place in Mosaic's canonical store. Matrix edit events (`m.replace`) are treated as new messages with `replyToId` pointing to the original, preserving the full audit trail.
- **Metadata (title, tags, archived state)**: Last-write-wins. The timestamp of the most recent write wins. Concurrent metadata updates from different surfaces are serialized through `ConversationService`; the final database write reflects the last persisted value.
- **Conversation membership**: Set-merge semantics. Adding a user from any surface is additive. Removal requires an explicit delete action and is not overwritten by concurrent adds.

### Session Isolation

Multiple TUI sessions or browser tabs connected to the same conversation receive all fanout messages independently. Each session maintains its own scroll position and local ephemeral state (typing indicator, draft text). Gateway does not synchronize ephemeral state across sessions.

---

## M7-005: Remote Auth Bridging

### Overview

Matrix users authenticate to Mosaic by linking their Matrix identity to an existing Mosaic account. There are two flows: token linking (primary) and OAuth bridge (alternative). Once linked, the Matrix session is persistent — there is no periodic login/logout cycle.

### Token Linking Flow

1. A Mosaic admin or the user themselves generates a short-lived link token via the Mosaic web UI or API (`POST /auth/channel-link-token`). The token is a cryptographically random 32-byte hex string with a 15-minute TTL stored in Valkey.
2. The user opens a Matrix client and sends a DM to `@mosaic-bot:homeserver`.
3. The user sends the command: `!link <token>`
4. The appservice receives the `m.room.message` event in the DM room, extracts the token, and calls `AuthService.linkChannelIdentity({ channel: 'matrix', channelUserId: matrixUserId, token })`.
5. `AuthService` validates the token, retrieves the associated `mosaicUserId`, and writes a row to `channel_identities`.
6. The appservice sends a confirmation reply in the DM room and invites the now-linked user to their personal Matrix Space.

```
User (Matrix)          @mosaic-bot          Mosaic Gateway
     │                     │                      │
     │  DM: !link <token>  │                      │
     │────────────────────▶│                      │
     │                     │  POST /auth/link     │
     │                     │─────────────────────▶│
     │                     │   200 OK             │
     │                     │◀─────────────────────│
     │  ✓ Linked! Joining  │                      │
     │  your Space now     │                      │
     │◀────────────────────│                      │
```

### OAuth Bridge Flow

An alternative flow for users who prefer browser-based authentication:

1. The Mosaic bot sends the user a Matrix message containing an OAuth URL: `https://mosaic.example.com/auth/matrix-link?state=<nonce>&matrix_user=<encoded_mxid>`
2. The user opens the URL in a browser. If not already logged in to Mosaic, they are redirected through the standard BetterAuth login flow.
3. On successful authentication, Mosaic records the `channel_identities` row linking `matrix_user` to the authenticated `mosaicUserId`.
4. The gateway sends a Matrix event to the pending DM room confirming the link.

### Invite-Based Provisioning

When a Mosaic admin adds a new user account, the provisioning flow optionally associates a Matrix user ID with the new account at creation time:

1. Admin provides `matrixUserId` when creating the account (`POST /admin/users`).
2. `UserService` writes the `channel_identities` row immediately.
3. The Matrix adapter's provisioning hook fires, and the appservice:
   - Creates the user's personal Matrix Space (if not already existing).
   - Sends an invite to the Matrix user for their personal Space.
   - Sends a welcome DM from `@mosaic-bot` with onboarding instructions.

The invited user does not need to complete any linking step — the association is pre-established by the admin.

### Session Lifecycle

Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessions (which terminate when the terminal process exits), a Matrix user's access to their rooms remains intact as long as:

- Their Mosaic account is active (not suspended or deleted).
- Their `channel_identities` row exists (link not revoked).
- They remain members of the relevant Matrix rooms.

Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) removes the `channel_identities` row and causes `mapIdentity()` to return `null`. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).

---

## M7-006: Agent-to-Agent Communication via Matrix

### Dedicated Agent Rooms

When two Mosaic agents need to coordinate, a dedicated Matrix room is created for their dialogue. This provides a persistent, auditable channel for structured inter-agent communication that humans can observe.

Room naming convention:

```
#mosaic-agents-<agentA>-<agentB>:homeserver
```

Where `agentA` and `agentB` are the Mosaic agent IDs sorted lexicographically (to ensure the same room is used regardless of which agent initiates). The room alias is registered by the appservice.

```
agent_rooms
  room_id     TEXT  -- Matrix room ID
  agent_a_id  TEXT  -- FK to agents.id (lexicographically first)
  agent_b_id  TEXT  -- FK to agents.id (lexicographically second)
  created_at  TIMESTAMP
  PRIMARY KEY (agent_a_id, agent_b_id)
```

### Room Membership and Power Levels

| Entity                             | Power Level                          |
| ---------------------------------- | ------------------------------------ |
| Mosaic appservice bot              | 100 (Admin)                          |
| Human observers (invited)          | 50 (Moderator, read-only by default) |
| Agent ghost users (agentA, agentB) | 0 (Default — message send only)      |

Humans are invited to agent rooms with a read-only intent. By convention, human messages in agent rooms are prefixed with `[HUMAN]` and treated as interrupts by the gateway. Agents are instructed (via system prompt) to pause and acknowledge human messages before resuming their dialogue.

### Message Format

Agents communicate using **structured JSON** embedded in Matrix event content. The Matrix event type is `m.room.message` with `msgtype: "m.text"` for compatibility. The structured payload is carried in a custom `mosaic.agent_message` field:

```json
{
  "msgtype": "m.text",
  "body": "[Agent message — see mosaic.agent_message for structured content]",
  "mosaic.agent_message": {
    "schema_version": "1.0",
    "sender_agent_id": "agent_abc123",
    "conversation_id": "conv_xyz789",
    "message_type": "request",
    "payload": {
      "action": "summarize",
      "parameters": { "max_tokens": 500 },
      "reply_to_event_id": "$previousEventId"
    },
    "timestamp_ms": 1711234567890
  }
}
```

The `body` field contains a human-readable fallback so the conversation is legible in any Matrix client. The structured payload is parsed exclusively by the gateway's Matrix adapter.

### Coordination Patterns

**Request/Response**: Agent A sends a `message_type: "request"` event. Agent B sends a `message_type: "response"` with `reply_to_event_id` referencing Agent A's event. The gateway correlates request/response pairs using the event IDs.

**Broadcast**: An agent sends a `message_type: "broadcast"` to a multi-agent room (more than two members). All agents in the room receive the event. No response is expected.

**Delegation**: Agent A sends a `message_type: "delegate"` with a `payload.task` object describing work to be handed off to Agent B. Agent B acknowledges with `message_type: "delegate_ack"` and later sends `message_type: "delegate_complete"` when done.

```
AgentA                Gateway               AgentB
  │   delegate(task)    │                     │
  │────────────────────▶│                     │
  │                     │  Matrix event push  │
  │                     │────────────────────▶│
  │                     │  delegate_ack       │
  │                     │◀────────────────────│
  │                     │  [AgentB executes]  │
  │                     │  delegate_complete  │
  │                     │◀────────────────────│
  │  task result        │                     │
  │◀────────────────────│                     │
```

### Gateway Mediation

Agents do not call the Matrix Client-Server API directly. All inter-agent Matrix events are sent and received by the gateway's appservice. This means:

- The gateway can intercept, log, and rate-limit agent-to-agent messages.
- Agents that are offline (no active process) still have their messages delivered; the gateway queues them and delivers on the agent's next activation.
- The gateway can inject system messages (e.g. human interrupts, safety stops) into agent rooms without agent cooperation.

---

## M7-007: Multi-User Isolation in Matrix

### Space-per-Team Architecture

Isolation in Matrix is enforced through the Space hierarchy. Each organizational boundary in Mosaic maps to a distinct Matrix Space:

| Mosaic entity                 | Matrix Space   | Visibility        |
| ----------------------------- | -------------- | ----------------- |
| Personal workspace (per user) | Personal Space | User only         |
| Team                          | Team Space     | Team members only |
| Public project                | (no Space)     | Configurable      |

Rooms (conversations) are placed into Spaces based on their sharing configuration. A room can appear in at most one team Space at a time. Moving a room from one team Space to another removes the `m.space.child` link from the old Space and adds it to the new one.

### Room Visibility Rules

Matrix room visibility within Conduit is controlled by:

1. **Join rules**: All Mosaic-managed rooms use `join_rule: invite`. Users cannot discover or join rooms without an explicit invite from the appservice.
2. **Space membership**: Rooms appear in a Space's directory only to users who are members of that Space.
3. **Room directory**: The server room directory is disabled for Mosaic-managed rooms (`m.room.history_visibility: shared` for team rooms, `m.room.history_visibility: invited` for personal rooms).

### Personal Space Defaults

When a user account is created (or linked to Matrix), the appservice provisions a personal Space:

- Space name: `<username>'s Space`
- All conversations the user creates personally are added as children of their personal Space.
- No other users are members of this Space by default.
- Conversation rooms within the personal Space are only visible and accessible to the owner.

### Team Shared Rooms

When a project or conversation is shared with a team:

1. The appservice adds the room as a child of the team's Space (`m.space.child` state event in the Space room, `m.space.parent` state event in the conversation room).
2. All current team members are invited to the conversation room.
3. Newly added team members are automatically invited to all shared rooms in the team's Space by the appservice's team membership hook.
4. If sharing is revoked, the appservice removes the `m.space.child` link and kicks all team members who joined via the team share (users who were directly invited are unaffected).

### Encryption

Encryption is optional and configured per room at creation time. Recommended defaults:

| Space type     | Encryption default | Rationale                              |
| -------------- | ------------------ | -------------------------------------- |
| Personal Space | Enabled            | Privacy-first for individual users     |
| Team Space     | Disabled           | Operational visibility; admin auditing |
| Agent rooms    | Disabled           | Gateway must read structured payloads  |

When encryption is enabled, the appservice's ghost users must participate in key exchange (using Matrix's Olm/Megolm protocol). The gateway holds the device keys for all ghost users it controls. This constraint means encrypted rooms require the gateway to be the E2E session holder — messages are end-to-end encrypted between human clients and gateway-held ghost device keys, not between human clients themselves.

### Admin Visibility

A Conduit server administrator can see:

- Room metadata: names, aliases, topic, membership list.
- Unencrypted event content in unencrypted rooms.

A Conduit server administrator **cannot** see:

- Content of encrypted rooms (without holding a device key for a room member).

Mosaic does not grant gateway admin credentials to application-level admin users. The Conduit admin interface is restricted to infrastructure operators. Application-level admins manage users and rooms through the Mosaic API, which interacts with the appservice layer only.

### Data Retention

Matrix events in Mosaic-managed rooms follow Mosaic's configurable retention policy:

```
room_retention_policies
  room_id          TEXT   -- Matrix room ID (or wildcard pattern)
  retention_days   INT    -- NULL = keep forever
  applies_to       TEXT   -- "personal" | "team" | "agent" | "all"
  created_at       TIMESTAMP
```

The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessage` record unless the Mosaic retention policy also covers it.

Default retention values:

| Room type                   | Default retention   |
| --------------------------- | ------------------- |
| Personal conversation rooms | 365 days            |
| Team conversation rooms     | 730 days            |
| Agent-to-agent rooms        | 90 days             |
| System/audit rooms          | 1825 days (5 years) |

Retention settings are configurable by Mosaic admins via the admin API and apply to both the Matrix event store and the Mosaic message store in lockstep.
