# Channel Protocol Architecture **Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative **Authors:** Mosaic Core Team **Last Updated:** 2026-07-14 **Covers:** M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto 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 implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups: 1. `OfficialChannelAdapter` — transport lifecycle and connection health. 2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data. 3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address. 4. `ChannelResponseTargetDto` — channel/thread destination for replies. All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter. --- ## M7-001: OfficialChannelAdapter Interface ```typescript interface OfficialChannelAdapter { /** Stable, lowercase adapter identifier such as "discord" or "matrix". */ readonly name: string; /** Establish both native-channel and gateway connections. */ start(): Promise; /** Gracefully close connections and release resources. */ stop(): Promise; /** Best-effort health; ordinary disconnection is a result, not an exception. */ health(): Promise<{ status: 'connected' | 'degraded' | 'disconnected'; detail?: string; }>; } ``` The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned. ### Stable conversation route ```typescript interface ChannelConversationRouteDto { bindingId: string; logicalAgentId: string; conversationId: string; channelName: string; authorizationChannelId: string; responseTarget: { channelId: string; threadId?: string }; } ``` Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target. ### Typed ingress and egress ports `ChannelIngressPort` is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same `ChannelIngressDto` before signing. The adapter uses a supplied `ChannelIngressPort` directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core. `ChannelBindingDto` contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits `ChannelIngressDto` to `ChannelIngressPort.receive()`. It includes the normalized message, `ChannelAuthorizedPrincipalDto`, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port. Gateway policy and runtime routing produce `ChannelEgressDto`, which `ChannelEgressPort.send()` delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics. ### Adapter Registration Adapters are registered with the gateway plugin host at startup. The host calls `start()`/`stop()` and may monitor `health()` on a configurable interval. A richer dynamic `ChannelRegistry` remains a compatible future extension of this lifecycle contract. ``` ChannelRegistry └── register(adapter: OfficialChannelAdapter): void └── getAdapter(name: string): OfficialChannelAdapter | null └── listAdapters(): OfficialChannelAdapter[] └── healthAll(): Promise> ``` --- ## M7-002: ChannelMessageDto Protocol ### Canonical Message Format ```typescript interface ChannelMessageDto { /** * 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. */ channelName: string; channelId: string; /** * Channel-native identifier of the message sender. * For Mosaic-originated messages this is the Mosaic userId or agentId. */ senderId: string; /** Sender classification. */ senderKind: '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 */ contentKind: '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: Readonly>; /** * 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?: readonly ChannelAttachmentDto[]; /** ISO-8601 wall-clock timestamp when the message was sent/received. */ timestamp: string; } interface ChannelAttachmentDto { /** Channel-native attachment identifier. */ id: string; /** Filename or display name. */ name: string; /** MIME type when supplied by the channel. */ mimeType: string | null; /** * 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 `ChannelMessageDto`. ### Matrix | ChannelMessageDto field | Matrix equivalent | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) | | `channelId` | Matrix room ID (`!roomid:homeserver`) | | `senderId` | Matrix user ID (`@user:homeserver`) | | `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound | | `content` | `event.content.body` | | `contentKind` | `"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 | ChannelMessageDto field | Discord equivalent | | ----------------------- | ----------------------------------------------------------------------- | | `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake | | `channelId` | Discord channel ID (snowflake string) | | `senderId` | Discord user ID (snowflake) | | `senderKind` | `"user"` for human members; `"agent"` for bot messages | | `content` | `message.content` | | `contentKind` | `"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 `ChannelAttachmentDto` | | `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 `contentKind = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. ### Discord routing and thread policy A configured Discord binding maps `(guildId, parentChannelId)` to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID. | Inbound location/trigger | Conversation and response target | | ------------------------------------------ | --------------------------------------------------------------- | | Authorized untagged parent-channel message | Parent channel; response is sent in-channel | | Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread | | Authorized message already in a thread | Existing thread; no repeated mention and no nested thread | | `/approve` or `/stop ` | Current parent/thread durable session; no new topic is created | Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target. ### Discord service ingress security The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads. --- ### Telegram | ChannelMessageDto 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) | | `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages | | `content` | `message.text` or `message.caption` | | `contentKind` | `"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 `ChannelAttachmentDto` | | `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 `contentKind = "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. | ChannelMessageDto field | TUI equivalent | | ----------------------- | ------------------------------------------------------------------ | | `id` | Generated UUID (TUI has no native message IDs) | | `channelId` | `"tui:"` — the active conversation ID | | `senderId` | Authenticated Mosaic `userId` | | `senderKind` | `"user"` for human input; `"agent"` for agent replies | | `content` | Raw text from stdin / agent output | | `contentKind` | `"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`). | ChannelMessageDto field | WebUI equivalent | | ----------------------- | ------------------------------------------------------------ | | `id` | Generated UUID; echoed back in the WebSocket event | | `channelId` | `"webui:"` | | `senderId` | Authenticated Mosaic `userId` | | `senderKind` | `"user"` for browser input; `"agent"` for agent responses | | `content` | Message text from the input field | | `contentKind` | `"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 Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity. 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 - `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready. - `ChannelEgressPort.send()` implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried. - `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 `ChannelMessageDto` 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: hs_token: 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: `. 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_: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 `ChannelMessageDto` 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 ` 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 │ │ │────────────────────▶│ │ │ │ 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=&matrix_user=` 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/`) removes the `channel_identities` row and causes gateway principal resolution to deny the identity. 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--: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: `'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 `ChannelMessageDto` 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.