Skip to main content

Messages & Channels

Messages represent immutable communication between entities within Stoneforge. They function similarly to email - once sent, they cannot be edited or deleted, providing a reliable audit trail.

Message Interface

interface Message extends Element {
  readonly type: 'message';
  
  // Location
  channelId: ChannelId;  // Mutable for channel merge operations
  
  // Sender
  readonly sender: EntityId;
  
  // Content
  readonly contentRef: DocumentId;  // Reference to content Document
  readonly attachments: readonly DocumentId[];  // Attachment Documents
  
  // Threading
  readonly threadId: MessageId | null;  // Parent message (null for root)
}
channelId
ChannelId
required
Channel containing this message (mutable for channel merge operations)
sender
EntityId
required
Entity that sent the message
contentRef
DocumentId
required
Reference to content Document (messages don’t store raw text)
attachments
DocumentId[]
required
References to attachment Documents (max 100 attachments)
threadId
MessageId | null
required
Parent message for threading (null for root messages)

Immutability

Messages are immutable after creation:
  • updatedAt always equals createdAt
  • Cannot be edited or deleted
  • Provides reliable audit trail
// Attempting to update or delete throws MessageImmutableError
rejectMessageUpdate(messageId: string): never
rejectMessageDelete(messageId: string): never

// Verify immutability constraint
verifyImmutabilityConstraint(message: Message): boolean
Messages cannot be updated or deleted. Design your message system accordingly - use correction messages or replies instead of editing.

Creating Messages

interface CreateMessageInput {
  channelId: ChannelId;
  sender: EntityId;
  contentRef: DocumentId;
  
  // Optional fields
  attachments?: DocumentId[];
  threadId?: MessageId | null;
  tags?: string[];
  metadata?: Record<string, unknown>;
}

// Create a new message
await createMessage(
  input: CreateMessageInput,
  config?: IdGeneratorConfig
): Promise<Message>
API/service layer is responsible for validating channel exists, sender is member, and documents are valid.

Message Threading

Root Messages vs. Replies

// Check if message is a root message
isRootMessage(message: Message): boolean

// Check if message is a reply
isReply(message: Message): boolean

Thread Utilities

// Get all messages in a thread (root + replies, sorted by time)
getThreadMessages<T extends Message>(
  messages: T[],
  rootMessageId: MessageId
): T[]

// Filter messages by thread
filterByThread<T extends Message>(messages: T[], threadId: MessageId): T[]

// Filter root messages only
filterRootMessages<T extends Message>(messages: T[]): T[]

// Filter reply messages only
filterReplies<T extends Message>(messages: T[]): T[]

Attachments

// Check if message has attachments
hasAttachments(message: Message): boolean

// Get attachment count
getAttachmentCount(message: Message): number
MAX_ATTACHMENTS
number
default:"100"
Maximum number of attachments per message

Filtering Messages

// Filter by channel
filterByChannel<T extends Message>(messages: T[], channelId: ChannelId): T[]

// Filter by sender
filterBySender<T extends Message>(messages: T[], sender: EntityId): T[]

// Filter by thread
filterByThread<T extends Message>(messages: T[], threadId: MessageId): T[]

// Filter root messages
filterRootMessages<T extends Message>(messages: T[]): T[]

// Filter replies
filterReplies<T extends Message>(messages: T[]): T[]

Sorting Messages

// Sort by creation time (oldest first)
sortByCreatedAt<T extends Message>(messages: T[]): T[]

// Sort by creation time (newest first)
sortByCreatedAtDesc<T extends Message>(messages: T[]): T[]

Grouping Messages

// Group by channel
groupByChannel<T extends Message>(messages: T[]): Map<ChannelId, T[]>

// Group by sender
groupBySender<T extends Message>(messages: T[]): Map<EntityId, T[]>

Message Utilities

// Check if message was sent by specific entity
isSentBy(message: Message, entityId: EntityId): boolean

// Check if message is in specific channel
isInChannel(message: Message, channelId: ChannelId): boolean

// Check if message is in specific thread
isInThread(message: Message, threadId: MessageId): boolean

Type Guards

// Check if value is a valid Message
isMessage(value: unknown): value is Message

// Comprehensive validation with detailed errors
validateMessage(value: unknown): Message

Channels

Channels are containers for messages, organizing communication into logical groups. They support both direct messaging (1:1) and group conversations.

Channel Interface

interface Channel extends Element {
  readonly type: 'channel';
  
  // Content
  readonly name: string;
  readonly description: string | null;
  
  // Channel Type
  readonly channelType: 'direct' | 'group';
  
  // Membership
  readonly members: readonly EntityId[];
  
  // Permissions
  readonly permissions: ChannelPermissions;
}

interface ChannelPermissions {
  readonly visibility: 'public' | 'private';
  readonly joinPolicy: 'open' | 'invite-only' | 'request';
  readonly modifyMembers: readonly EntityId[];
}

Channel Types

1:1 communication between two entities:
  • Exactly 2 members (immutable)
  • Deterministic name (sorted entity IDs joined by colon)
  • Always private visibility
  • Always invite-only join policy
  • No one can modify membership
// Generate direct channel name
generateDirectChannelName(entityA: EntityId, entityB: EntityId): string
// Returns: "entityA:entityB" (sorted)

// Parse entity IDs from name
parseDirectChannelName(name: string): [EntityId, EntityId] | null

Creating Channels

Direct Channel

interface CreateDirectChannelInput {
  entityA: EntityId;
  entityB: EntityId;
  createdBy: EntityId;  // Must be entityA or entityB
  
  // Optional fields
  entityAName?: string;  // Display name for entity A
  entityBName?: string;  // Display name for entity B
  description?: string | null;
  tags?: string[];
  metadata?: Record<string, unknown>;
}

// Create direct channel
await createDirectChannel(
  input: CreateDirectChannelInput,
  config?: IdGeneratorConfig
): Promise<Channel>

Group Channel

interface CreateGroupChannelInput {
  name: string;
  createdBy: EntityId;
  
  // Optional fields
  members?: EntityId[];  // Creator automatically included
  description?: string | null;
  visibility?: 'public' | 'private';  // default: private
  joinPolicy?: 'open' | 'invite-only' | 'request';  // default: invite-only
  modifyMembers?: EntityId[];  // Creator automatically included
  tags?: string[];
  metadata?: Record<string, unknown>;
}

// Create group channel
await createGroupChannel(
  input: CreateGroupChannelInput,
  config?: IdGeneratorConfig
): Promise<Channel>

Channel Membership

Membership Checks

// Check if entity is a member
isMember(channel: Channel, entityId: EntityId): boolean

// Check if entity can modify membership
canModifyMembers(channel: Channel, entityId: EntityId): boolean

// Check if entity can join
canJoin(channel: Channel, entityId: EntityId): boolean

// Get member count
getMemberCount(channel: Channel): number

Membership Errors

// Thrown when attempting to modify direct channel membership
class DirectChannelMembershipError extends ConstraintError

// Thrown when entity is not a member
class NotAMemberError extends ConstraintError

// Thrown when entity lacks permission
class CannotModifyMembersError extends ConstraintError

Channel Permissions

Visibility

const VisibilityValue = {
  PUBLIC: 'public',   // Discoverable by all
  PRIVATE: 'private', // Invitation only
} as const;

// Check visibility
isPublicChannel(channel: Channel): boolean
isPrivateChannel(channel: Channel): boolean

Join Policy

const JoinPolicyValue = {
  OPEN: 'open',           // Anyone can join (if public)
  INVITE_ONLY: 'invite-only',  // Must be added by modifier
  REQUEST: 'request',     // Can request to join (approval needed)
} as const;

Filtering Channels

// Filter by type
filterByChannelType<T extends Channel>(channels: T[], channelType: ChannelType): T[]
filterDirectChannels<T extends Channel>(channels: T[]): T[]
filterGroupChannels<T extends Channel>(channels: T[]): T[]

// Filter by member
filterByMember<T extends Channel>(channels: T[], entityId: EntityId): T[]

// Filter by visibility
filterByVisibility<T extends Channel>(channels: T[], visibility: Visibility): T[]
filterPublicChannels<T extends Channel>(channels: T[]): T[]
filterPrivateChannels<T extends Channel>(channels: T[]): T[]

Finding Channels

// Find direct channel between two entities
findDirectChannel<T extends Channel>(
  channels: T[],
  entityA: EntityId,
  entityB: EntityId
): T | undefined

// Get all direct channels for an entity
getDirectChannelsForEntity<T extends Channel>(
  channels: T[],
  entityId: EntityId
): T[]

Sorting Channels

// Sort by name
sortByName<T extends Channel>(channels: T[]): T[]

// Sort by member count
sortByMemberCount<T extends Channel>(channels: T[]): T[]

// Sort by creation time (newest first)
sortByCreatedAtDesc<T extends Channel>(channels: T[]): T[]

Grouping Channels

// Group by visibility
groupByVisibility<T extends Channel>(channels: T[]): Map<Visibility, T[]>

// Group by type
groupByChannelType<T extends Channel>(channels: T[]): Map<ChannelType, T[]>

Validation Constants

MIN_CHANNEL_NAME_LENGTH
number
default:"1"
Minimum channel name length
MAX_CHANNEL_NAME_LENGTH
number
default:"100"
Maximum channel name length
MAX_CHANNEL_MEMBERS
number
default:"1000"
Maximum members in a channel
MIN_GROUP_MEMBERS
number
default:"2"
Minimum members for a group channel
DIRECT_CHANNEL_MEMBERS
number
default:"2"
Exact member count for direct channels

Type Guards

// Check if value is a valid Channel
isChannel(value: unknown): value is Channel

// Check channel type
isDirectChannel(channel: Channel): boolean
isGroupChannel(channel: Channel): boolean

// Comprehensive validation
validateChannel(value: unknown): Channel
validateDirectChannelConstraints(channel: Channel): boolean

Best Practices

Messages Are Immutable

Design your system around immutable messages. Use replies or correction messages instead of editing.

Content as Documents

Always store message content in Document elements and reference via contentRef.

Direct Channel Naming

Use generateDirectChannelName() for consistent, deterministic naming.

Membership Validation

Validate sender is a channel member before creating messages.

Build docs developers (and LLMs) love