Files
stack/docs/DEVELOPER-GUIDE/architecture/channel-protocol.md
T

26 KiB

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.

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, 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 and 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, 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, plugin.module.ts, and plugin.service.ts The lifecycle registry that exists today. It is not evidence of a shared OfficialChannelAdapter registry.
Telegram plugins/telegram/src/index.ts and 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:

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:

<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:

Discord evidence:

Hosting and compatibility evidence:

Telegram evidence: