docs: migrate channel protocol with evidence boundaries

This commit is contained in:
Jason Woltje
2026-08-10 18:13:46 -05:00
parent 631567d5f7
commit 00bdf8b28c
2 changed files with 279 additions and 751 deletions
@@ -0,0 +1,279 @@
# Channel protocol architecture
> **Status:** Current shared type contract and Discord compatibility baseline. The shared gateway registry, Telegram parity, Matrix integration, identity-linking, and multi-surface multiplexing described below are draft or unimplemented.
>
> **Audience:** Developers maintaining `@mosaicstack/types`, channel plugins, the gateway chat/plugin boundaries, or future official adapters.
>
> **Last verified:** 2026-08-10 against the source and focused tests listed in [Evidence](#evidence).
>
> **Authority:** Executable source and tests are authoritative for current behavior. This page explains the boundary; it is not a runtime registry, an API contract, a requirements document, or proof that every channel uses the shared DTOs.
## Reading this page
This page intentionally separates three states:
- **Current** — implemented in the repository and supported by the cited tests.
- **Compatibility** — an existing wire path that preserves current behavior but does not yet mean that the shared channel ports are wired through the gateway.
- **Draft** — a design direction or follow-up work item. Draft sections have no implementation authority and must not be used as instructions for operating Telegram, Matrix, identity linking, or cross-surface fanout.
The migration from `docs/_old_structure/architecture/channel-protocol.md` is a documentation correction. It does not add adapters, change gateway behavior, change authentication, or create database objects.
## Authority and evidence boundaries
| Boundary | Current authority | What this page may claim |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Shared channel types and ports | [`channel.dto.ts`](../../../packages/types/src/channel/channel.dto.ts), [`channel-adapter.ts`](../../../packages/types/src/channel/channel-adapter.ts), and their exports | The TypeScript shapes and method signatures that are currently published from `@mosaicstack/types`. |
| Discord native behavior | [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) and [`index.test.ts`](../../../plugins/discord/src/index.test.ts) | The Discord allowlist, pairing, role, thread, ingress, egress, retry, and health behavior covered by source and tests. |
| Discord gateway compatibility | [`chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts), [`chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts), and the focused gateway tests | The signed Socket.IO service path, gateway validation, raw chat events, and current session dispatch behavior. |
| Plugin hosting | [`plugin.interface.ts`](../../../apps/gateway/src/plugin/plugin.interface.ts), [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts), and [`plugin.service.ts`](../../../apps/gateway/src/plugin/plugin.service.ts) | The lifecycle registry that exists today. It is not evidence of a shared `OfficialChannelAdapter` registry. |
| Telegram | [`plugins/telegram/src/index.ts`](../../../plugins/telegram/src/index.ts) and [`package.json`](../../../plugins/telegram/package.json) | The raw legacy behavior that exists. It is not evidence of shared-contract parity or a working authenticated gateway integration. |
| Matrix, identity linking, and multiplexing | No matching current implementation and test boundary was found for the old page's designs | These topics remain explicitly draft/unimplemented here. |
The current source boundaries also distinguish two identities:
1. A channel route carries a configuration-owned logical agent and response destination.
2. The gateway chooses the durable session, provider, model, and runtime session internally.
A route is therefore not a claim that a channel adapter owns or exposes a harness, provider, model, process, or native runtime-session identity.
## Current shared contract
The channel types are exported through `packages/types/src/channel/index.ts` and `packages/types/src/index.ts`. They define a transport-neutral vocabulary, but TypeScript interfaces alone do not prove that every producer or consumer uses that vocabulary.
### DTOs
The current DTO surface is:
| Type | Current shape and boundary |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChannelMetadataValue` | JSON-safe strings, numbers, booleans, `null`, arrays, and nested objects. |
| `ChannelAttachmentDto` | `id`, `name`, `mimeType`, `url`, and optional `sizeBytes`. |
| `ChannelMessageDto` | `id`, `channelName`, `channelId`, `senderId`, `senderKind`, `content`, `contentKind`, `timestamp`, and `metadata`; `threadId`, `replyToId`, and `attachments` are optional. |
| `ChannelAuthorizedPrincipalDto` | Native `channelUserId`, a `viewer`/`operator`/`admin` role, and an optional `mosaicUserId` for privileged gateway policy. |
| `ChannelBindingDto` | Configuration-owned `bindingId`, `channelName`, `workspaceId`, `channelId`, `logicalAgentId`, and paired `principals`. Credentials are intentionally absent. |
| `ChannelResponseTargetDto` | `channelId` and an optional `threadId`. |
| `ChannelConversationRouteDto` | `bindingId`, `logicalAgentId`, `conversationId`, `channelName`, `authorizationChannelId`, and `responseTarget`. |
| `ChannelIngressDto` | `correlationId`, `nativeMessageId`, an operation, an authorized principal, a normalized message, and a stable route. |
| `ChannelEgressDto` | `correlationId`, a normalized message, and the stable route. |
| `ChannelAdapterHealthDto` | `status` of `connected`, `degraded`, or `disconnected`, with optional `detail`. |
The available operations are `message.send`, `approval.create`, and `session.stop`. The available sender kinds are `user`, `agent`, and `system`; content kinds are `text`, `markdown`, `code`, `image`, and `file`.
### Lifecycle and ports
The shared adapter file currently defines these seams:
```typescript
interface OfficialChannelAdapter {
readonly name: string;
start(): Promise<void>;
stop(): Promise<void>;
health(): Promise<ChannelAdapterHealthDto>;
}
interface ChannelIngressPort {
receive(ingress: ChannelIngressDto): Promise<void>;
}
interface ChannelEgressPort {
send(egress: ChannelEgressDto): Promise<void>;
}
```
`ChannelDeliveryError` currently has only these codes: `invalid_route`, `destination_unavailable`, and `delivery_failed`. The type surface does not define a revoked-auth error code or an executable protocol version `1.0.0`.
### Stable route rule
`ChannelConversationRouteDto` deliberately omits provider, harness, model, process, and native runtime-session fields. The Discord implementation derives its current conversation address as:
```text
<logical-agent-id>:discord:<response-channel-id>
```
and derives its binding address from the configured guild, parent channel, and logical-agent instance. The gateway validates the expected Discord conversation address before dispatch. This is a route-integrity rule, not a claim that the shared DTO is already the gateway's universal session API.
### What is and is not wired today
The Discord class implements both `OfficialChannelAdapter` and `ChannelEgressPort`, and accepts an optional `ChannelIngressPort` dependency. The direct ingress seam is exercised by the Discord tests. However, the gateway host currently registers `IChannelPlugin` objects with only `name`, `start`, `stop`, and optional project provisioning. Its `PLUGIN_REGISTRY` is an array of those lifecycle wrappers; it does not expose `health()`, `ChannelRegistry.healthAll()`, or shared port wiring.
The gateway's current output path is also still Socket.IO event streaming (`agent:start`, `agent:text`, and `agent:end`). No gateway service in the cited implementation produces a `ChannelEgressDto` for a registered adapter. The shared ports are therefore current contracts and a tested Discord seam, not a completed gateway-wide adapter architecture.
## Current Discord compatibility path
Discord is the current reference implementation for the shared contract and the compatibility path. Its behavior is split between native Discord translation in the plugin and gateway-side validation/dispatch.
### Native ingress and authorization
For an inbound Discord message, the plugin currently:
1. Ignores bot-authored messages and messages without a guild.
2. Uses the configured parent text channel as the authorization channel only when the message is in a thread. A normal channel's category parent is not substituted for the channel itself.
3. Applies default-deny guild, channel, and user allowlists.
4. Resolves a configuration-owned binding and paired user role before creating a thread or dispatching to the gateway. `viewer` cannot send turns; approval and stop are admin operations.
5. Applies per-user/channel message and mention-thread rate limits before Discord thread creation or gateway dispatch.
6. Builds a stable route from the configured logical-agent instance and the response channel/thread.
7. Normalizes the authorized turn to `ChannelIngressDto` when a direct `ingressPort` dependency is supplied.
The normalized `ChannelMessageDto` currently includes:
- `channelName: "discord"`;
- the response channel as `channelId`;
- the Discord author as `senderId` and `senderKind: "user"`;
- `markdown` for non-empty text, or `image`/`file` for attachment-only input;
- attachments mapped to `ChannelAttachmentDto`; and
- `metadata` containing `channelMessageId` and `guildId`.
The implementation does **not** currently populate `channelType`, mentions, embeds, or `replyToId` in that normalized metadata. The old page's broader Discord metadata table must not be treated as current behavior.
### Direct shared ingress versus compatibility envelope
When a direct port is present, the plugin calls `ChannelIngressPort.receive()` with the complete normalized ingress DTO. In the current gateway-hosted path, the plugin instead signs a compatibility envelope and emits one of these Socket.IO events:
| Shared operation | Compatibility event | Current envelope boundary |
| ----------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `message.send` | `message` | Correlation ID, native Discord message ID, guild/channel/user IDs, conversation ID, content, optional thread ID, and attachments. |
| `approval.create` | `discord:approve` | The same signed Discord identity and route fields, carrying the approval command. |
| `session.stop` | `discord:stop` | The same signed Discord identity and route fields, carrying the stop command. |
The signature is HMAC-SHA-256 over the ordered envelope payload using the injected Discord service token. The token is used for service authentication and is not part of the protocol payload.
### Gateway validation and dispatch
The gateway exposes the `/chat` Socket.IO namespace. A Discord connection authenticates with `discordServiceToken`; ordinary clients use a BetterAuth session. For Discord service messages, the gateway:
1. Verifies the signed envelope with `DISCORD_SERVICE_TOKEN`.
2. Re-applies the configured guild, channel, and user allowlists.
3. Resolves the configured binding and operation role.
4. Checks that the conversation ID matches the bound logical-agent instance and channel/thread.
5. Rejects a repeated native Discord message ID through the bounded replay protector.
6. Reconstructs a gateway `ChatSocketMessageDto` containing the conversation ID, content, and validated attachments.
7. Uses the configured Discord service principal/tenant for ordinary chat dispatch and the paired `mosaicUserId` for privileged approval/stop policy where required.
8. Selects the trusted `agentConfigId` from the binding and verifies that the provisioned agent name matches the binding's logical-agent instance.
This path is intentionally described as compatibility: the gateway receives a signed Discord envelope and reconstructs chat input; it does not currently receive a complete `ChannelIngressDto` from the host registry.
### Thread and conversation behavior
| Inbound case | Current route and side effect |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Authorized untagged message in a parent channel | Uses the parent channel as the response target; no thread is created. |
| Authorized bot mention in a parent channel | Creates a public thread, or reuses the thread already attached to that message, and routes the response to that thread. |
| Authorized follow-up in an existing thread | Authorizes against the configured parent channel and keeps the existing thread; it does not create a nested thread. |
| `/approve` or `/stop <approval>` | Uses the current parent/thread durable session and does not create a new topic. |
| Requested thread creation fails | Does not dispatch the message, because the requested response target cannot be honored. |
### Discord egress and health
`DiscordPlugin.send()` is a typed egress implementation, even though the current gateway does not wire it as a universal `ChannelEgressPort`. It:
- rejects a forged or route-misaligned conversation before looking up a Discord destination;
- rejects a missing destination with `destination_unavailable`;
- chunks text at a Discord-safe 1,900-character boundary;
- retries transient rate-limit, server, and network failures up to three attempts;
- derives one stable nonce per correlation/chunk and sends with Discord's enforced nonce option; and
- does not retry permanent delivery failures.
The plugin reports `connected`, `degraded`, or `disconnected` from Discord client readiness and gateway socket connectivity. The health method is tested without exposing provider or runtime state. Outbound delivery uses Discord's native send operation; code-content wrapping from the former page is not implemented.
Agent output reaches the plugin through the current `agent:start`/`agent:text`/`agent:end` Socket.IO events. The plugin buffers the text by conversation ID and calls its typed Discord egress method when the stream ends.
## Draft: shared adapter registry and gateway wiring
**Status: Draft / unimplemented.**
The gateway does have a startup `IChannelPlugin[]` registry, but that registry is a lifecycle host for the current Discord and Telegram wrappers. It does not register `OfficialChannelAdapter` instances, inject `ChannelIngressPort` and `ChannelEgressPort` through a common gateway service, expose adapter health, or implement a dynamic `ChannelRegistry` with `getAdapter`, `listAdapters`, and `healthAll` semantics.
The former page's claim that adapters are already registered uniformly, or that new adapters can be added without channel-specific gateway branches, is not current. The gateway still has Discord-specific authentication, envelope, approval, stop, replay, and binding branches.
A future implementation may define a registry and host lifecycle, but that work must first specify:
- ownership and injection of ingress and egress ports;
- health and failure semantics;
- binding and credential loading boundaries;
- compatibility behavior for existing Socket.IO clients; and
- tests proving that an adapter cannot bypass gateway authorization or route validation.
Until then, this section is design context only.
## Draft: Telegram shared-contract parity
**Status: Raw legacy adapter exists; shared protocol parity and authenticated gateway participation are unimplemented/unproven.**
The current Telegram plugin is not an `OfficialChannelAdapter` implementation. Its source currently:
- launches a Telegraf bot and a Socket.IO client;
- accepts only messages with a text field and ignores attachment-only messages;
- maps each Telegram chat ID to `telegram-<chatId>`;
- emits a raw `{ conversationId, content, role: "user" }` object rather than `ChannelIngressDto`;
- has no shared DTO import, channel binding, principal/role policy, native message ID, attachment mapping, route validation, or health method; and
- sends plain `sendMessage` responses in chunks, without the former page's claimed MarkdownV2, photo, or document handling.
The Telegram Socket.IO client does not provide the Discord service token or a BetterAuth session in its connection options. The source therefore does not establish participation in the gateway's current authenticated connection path. The package's test script uses `--passWithNoTests`, and no package test file is present in this checkout.
Future Telegram parity is draft work. It would need an explicit identity/authentication boundary, shared ingress normalization, binding and operation policy, route-safe egress, health reporting, and focused tests before this page could describe Telegram as an official shared-contract adapter.
## Draft: Matrix integration
**Status: Draft / unimplemented in the channel protocol.**
No current gateway adapter, shared-port wiring, channel binding, identity resolver, room/conversation persistence boundary, or focused channel tests were found for the Matrix design described by the former page. The old Conduit choice, appservice registration, room and Space mappings, ghost users, encryption defaults, retention jobs, and agent-room behavior are therefore proposals, not current system behavior.
Those details must not be copied into implementation instructions or treated as deployment requirements. A future Matrix effort must independently decide and implement its homeserver/appservice boundary, authentication, route mapping, persistence, authorization, delivery, and tests.
## Draft: channel identity linking
**Status: Draft / unimplemented.**
The shared contract carries an already-authorized `ChannelAuthorizedPrincipalDto`; it does not implement a generic channel-identity database or linking flow. Discord currently uses configuration-owned allowlists and pairings. A pairing may include a `mosaicUserId` for privileged operations, while ordinary Discord chat dispatch uses the configured service principal and tenant in the gateway.
No current evidence establishes the former page's proposed `channel_identities` table, OAuth/deep-link flow, anonymous-principal behavior, persistent Matrix session, or revocation endpoint. Those are not implied by the optional `mosaicUserId` field and must remain planned work until schema, auth, gateway, and adapter implementations exist together.
## Draft: multi-surface conversation multiplexing
**Status: Partial raw chat-session support exists; the proposed channel-protocol fanout architecture is unimplemented.**
The gateway currently tracks client/conversation sessions and emits raw typed Socket.IO chat events. Focused gateway tests verify isolation of concurrent conversation streams sharing one socket. The chat event contract is `ChatMessagePayload` plus `agent:*` events, not `ChannelMessageDto` fanout.
There is no evidence in the cited current path for the former page's complete `ConversationService` plus Valkey pub/sub topology, canonical cross-surface `ChannelMessageDto` persistence, or Matrix fanout. Concurrent stream isolation must not be presented as multi-surface channel multiplexing.
A future multiplexing design must define canonical message ownership, subscription and fanout boundaries, replay/ordering behavior, conflict semantics, and per-surface authorization before it can become architecture guidance.
## Current limitations and version boundary
The following are intentionally not claimed as current protocol policy:
- A semantic protocol version of `1.0.0`. `packages/types/package.json` currently reports package version `0.0.2`, while `packages/types/src/index.ts` exports `VERSION = "0.0.0"`; no migration machinery is present in the cited channel code.
- A generic revoked-auth `ChannelDeliveryError` code. The current union contains only `invalid_route`, `destination_unavailable`, and `delivery_failed`.
- Structured log records with a universal `{ channel, event, ... }` schema. Current plugin logs are string-prefixed.
- Universal adapter health monitoring by the gateway host. Discord exposes health; the `IChannelPlugin` host does not.
- Complete `ChannelEgressDto` delivery from the gateway to every channel. Discord's current gateway egress remains Socket.IO stream events followed by plugin-side delivery.
These limitations are evidence boundaries, not requests to change implementation in this documentation migration.
## Evidence
Current-contract evidence:
- [`packages/types/src/channel/channel.dto.ts`](../../../packages/types/src/channel/channel.dto.ts) — DTOs, enums, route fields, and metadata shape.
- [`packages/types/src/channel/channel-adapter.ts`](../../../packages/types/src/channel/channel-adapter.ts) — adapter lifecycle, ingress/egress ports, and delivery errors.
- [`packages/types/src/channel/index.ts`](../../../packages/types/src/channel/index.ts) and [`packages/types/src/index.ts`](../../../packages/types/src/index.ts) — export surface and current package `VERSION`.
Discord evidence:
- [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) — native translation, authorization, thread routing, signed compatibility envelope, typed ingress/egress seam, retry, and health.
- [`plugins/discord/src/index.test.ts`](../../../plugins/discord/src/index.test.ts) — direct ingress, routing/thread behavior, authorization ordering, attachments, route-safe egress, retries, chunking, and health.
- [`apps/gateway/src/chat/chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts) — `/chat` namespace, service/session authentication, signed-envelope reconstruction, trusted binding selection, and raw stream egress.
- [`apps/gateway/src/chat/chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts) — Discord service-token and BetterAuth session validation.
- [`apps/gateway/src/plugin/discord-ingress.security.spec.ts`](../../../apps/gateway/src/plugin/discord-ingress.security.spec.ts) — signature, allowlist, replay, binding, attachment, approval, stop, and logical-agent checks.
- [`apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts`](../../../apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts) — focused Discord-to-durable-session integration.
Hosting and compatibility evidence:
- [`apps/gateway/src/plugin/plugin.interface.ts`](../../../apps/gateway/src/plugin/plugin.interface.ts), [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts), and [`plugin.service.ts`](../../../apps/gateway/src/plugin/plugin.service.ts) — current lifecycle-only plugin host.
- [`packages/types/src/chat/events.ts`](../../../packages/types/src/chat/events.ts) — raw Socket.IO chat event contracts.
- [`apps/gateway/src/chat/chat.gateway-redaction.spec.ts`](../../../apps/gateway/src/chat/chat.gateway-redaction.spec.ts) — current per-client/per-conversation stream isolation evidence.
Telegram evidence:
- [`plugins/telegram/src/index.ts`](../../../plugins/telegram/src/index.ts) — current raw Telegraf and Socket.IO behavior.
- [`plugins/telegram/package.json`](../../../plugins/telegram/package.json) — package scripts, including `--passWithNoTests`.
@@ -1,751 +0,0 @@
# 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<void>;
/** Gracefully close connections and release resources. */
stop(): Promise<void>;
/** 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<Record<string, AdapterHealth>>
```
---
## 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<Record<string, ChannelMetadataValue>>;
/**
* 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 <approval>` | 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:<conversationId>"` — 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:<conversationId>"` |
| `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: <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 `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 <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 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-<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 `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.