Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
39 KiB
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:
IChannelAdapter— the interface each channel driver must implement.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
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
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 ofChannelConnectError) if the initial connection cannot be established within a reasonable timeout (default: 10 s).sendMessage()must throwChannelSendErroron 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:
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
- A message arrives on any surface (TUI keystroke, browser send, Matrix event).
- The surface's adapter normalizes the message to
ChannelMessageand delivers it toConversationService. ConversationServicepersists the message to PostgreSQL, assigns a canonicalid, and publishes amessage:newevent to the Valkey pub/sub channel keyed byconversationId.- All active surfaces subscribed to that
conversationIdreceive 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:messageSocket.IO event to all browser sessions joined to that conversation. - Matrix adapter: sends an
m.room.messageevent 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 withreplyToIdpointing 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
- 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. - The user opens a Matrix client and sends a DM to
@mosaic-bot:homeserver. - The user sends the command:
!link <token> - The appservice receives the
m.room.messageevent in the DM room, extracts the token, and callsAuthService.linkChannelIdentity({ channel: 'matrix', channelUserId: matrixUserId, token }). AuthServicevalidates the token, retrieves the associatedmosaicUserId, and writes a row tochannel_identities.- 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:
- 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> - The user opens the URL in a browser. If not already logged in to Mosaic, they are redirected through the standard BetterAuth login flow.
- On successful authentication, Mosaic records the
channel_identitiesrow linkingmatrix_userto the authenticatedmosaicUserId. - 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:
- Admin provides
matrixUserIdwhen creating the account (POST /admin/users). UserServicewrites thechannel_identitiesrow immediately.- 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-botwith 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_identitiesrow 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:
{
"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:
- Join rules: All Mosaic-managed rooms use
join_rule: invite. Users cannot discover or join rooms without an explicit invite from the appservice. - Space membership: Rooms appear in a Space's directory only to users who are members of that Space.
- Room directory: The server room directory is disabled for Mosaic-managed rooms (
m.room.history_visibility: sharedfor team rooms,m.room.history_visibility: invitedfor 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:
- The appservice adds the room as a child of the team's Space (
m.space.childstate event in the Space room,m.space.parentstate event in the conversation room). - All current team members are invited to the conversation room.
- Newly added team members are automatically invited to all shared rooms in the team's Space by the appservice's team membership hook.
- If sharing is revoked, the appservice removes the
m.space.childlink 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.