Compare commits

...

4 Commits

Author SHA1 Message Date
2e2280070a docs(#753): clear KBN-010 threat and schema gate (#765)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 21:46:44 +00:00
aa5b43bba2 feat(fleet): add roster v2 structural compiler (#764)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 20:37:52 +00:00
ba13c08890 Fixes #756 (#763)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-14 20:26:15 +00:00
c32d85a337 docs(fleet): define declarative configuration M0 (#760)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 19:53:12 +00:00
42 changed files with 4686 additions and 404 deletions

View File

@@ -72,6 +72,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1', guildId: 'guild-1',
channelId: 'channel-1', channelId: 'channel-1',
pairedUsers: { pairedUsers: {
@@ -133,6 +134,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
interactionBindings: [ interactionBindings: [
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1', guildId: 'guild-1',
channelId: 'channel-1', channelId: 'channel-1',
pairedUsers: { pairedUsers: {

View File

@@ -115,6 +115,18 @@ describe('AgentService owner/tenant scope enforcement', () => {
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE); await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt'); expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
await service.prompt(CONVERSATION_ID, '', OWNER_SCOPE, [
{
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
mimeType: 'image/png',
},
]);
expect(session.piSession.prompt).toHaveBeenLastCalledWith(
'\n\n[Untrusted channel attachments]\n' +
'{"id":"attachment-001","name":"diagram.png","mimeType":"image/png","url":"https://cdn.example.test/diagram.png"}',
);
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf( await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException, ForbiddenException,

View File

@@ -15,6 +15,7 @@ import {
type ToolDefinition, type ToolDefinition,
} from '@mariozechner/pi-coding-agent'; } from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaicstack/brain'; import type { Brain } from '@mosaicstack/brain';
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory'; import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js'; import { MEMORY } from '../memory/memory.tokens.js';
@@ -43,6 +44,8 @@ export interface ConversationHistoryMessage {
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
createdAt: Date; createdAt: Date;
/** Validated, URI-referenced channel attachments preserved on session resume. */
attachments?: readonly ChannelAttachmentDto[];
} }
export interface AgentSessionOptions { export interface AgentSessionOptions {
@@ -428,7 +431,7 @@ export class AgentService implements OnModuleDestroy {
const formatMessage = (msg: ConversationHistoryMessage): string => { const formatMessage = (msg: ConversationHistoryMessage): string => {
const roleLabel = const roleLabel =
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System'; msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
return `**${roleLabel}:** ${msg.content}`; return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`;
}; };
const formatted = history.map((msg) => formatMessage(msg)); const formatted = history.map((msg) => formatMessage(msg));
@@ -487,6 +490,21 @@ export class AgentService implements OnModuleDestroy {
return result; return result;
} }
private attachmentContext(attachments: readonly ChannelAttachmentDto[]): string {
if (attachments.length === 0) return '';
return `\n\n[Untrusted channel attachments]\n${attachments
.map((attachment: ChannelAttachmentDto): string =>
JSON.stringify({
id: attachment.id,
name: attachment.name,
mimeType: attachment.mimeType,
url: attachment.url,
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
}),
)
.join('\n')}`;
}
private resolveModel(options?: AgentSessionOptions) { private resolveModel(options?: AgentSessionOptions) {
if (!options?.provider && !options?.modelId) { if (!options?.provider && !options?.modelId) {
return this.providerService.getDefaultModel() ?? null; return this.providerService.getDefaultModel() ?? null;
@@ -673,7 +691,19 @@ export class AgentService implements OnModuleDestroy {
session.channels.delete(channel); session.channels.delete(channel);
} }
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> { async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] | undefined,
): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] = [],
): Promise<void> {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
throw new Error(`No agent session found: ${sessionId}`); throw new Error(`No agent session found: ${sessionId}`);
@@ -681,12 +711,16 @@ export class AgentService implements OnModuleDestroy {
this.assertSessionScope(session, scope); this.assertSessionScope(session, scope);
session.promptCount += 1; session.promptCount += 1;
// Channel attachments are untrusted URI references. Preserve exact,
// authenticated metadata for the agent without treating it as authority.
const attachmentContext = this.attachmentContext(attachments);
// Prepend session-scoped system override if present (renew TTL on each turn) // Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = message; let effectiveMessage = `${message}${attachmentContext}`;
if (this.systemOverride) { if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId, scope); const override = await this.systemOverride.get(sessionId, scope);
if (override) { if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${message}`; effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope); await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`); this.logger.debug(`Applied system override for session ${sessionId}`);
} }

View File

@@ -1,3 +1,4 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto { export class ChatRequestDto {
@@ -32,4 +33,7 @@ export class ChatSocketMessageDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
agentId?: string; agentId?: string;
/** Validated channel attachment references; binary content is not embedded. */
attachments?: readonly ChannelAttachmentDto[];
} }

View File

@@ -4,6 +4,10 @@ import { ChatGateway } from './chat.gateway.js';
const CONVERSATION_ID = 'conversation-1'; const CONVERSATION_ID = 'conversation-1';
const CANARY = 'sk_canary12345678'; const CANARY = 'sk_canary12345678';
function clientConversationKey(clientId: string, conversationId: string): string {
return `${clientId}\u0000${conversationId}`;
}
type GatewayInternals = { type GatewayInternals = {
clientSessions: Map<string, unknown>; clientSessions: Map<string, unknown>;
relayEvent(client: unknown, conversationId: string, event: unknown): void; relayEvent(client: unknown, conversationId: string, event: unknown): void;
@@ -40,6 +44,7 @@ describe('ChatGateway redaction boundary', (): void => {
emit: vi.fn(), emit: vi.fn(),
}; };
const session = { const session = {
clientId: client.id,
conversationId: CONVERSATION_ID, conversationId: CONVERSATION_ID,
cleanup: vi.fn(), cleanup: vi.fn(),
assistantText: '', assistantText: '',
@@ -47,7 +52,7 @@ describe('ChatGateway redaction boundary', (): void => {
pendingToolCalls: new Map(), pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' }, scope: { userId: 'user-1', tenantId: 'tenant-1' },
}; };
gateway.clientSessions.set(client.id, session); gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session);
gateway.relayEvent(client, CONVERSATION_ID, { gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update', type: 'message_update',
@@ -139,6 +144,51 @@ describe('ChatGateway redaction boundary', (): void => {
}); });
}); });
it('isolates concurrent conversation streams sharing one Discord socket', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'discord-client',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
const firstConversation = 'Nova:discord:thread-1';
const secondConversation = 'Nova:discord:thread-2';
const createSession = (conversationId: string) => ({
clientId: client.id,
conversationId,
cleanup: vi.fn(),
assistantText: '',
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
});
const firstSession = createSession(firstConversation);
const secondSession = createSession(secondConversation);
gateway.clientSessions.set(clientConversationKey(client.id, firstConversation), firstSession);
gateway.clientSessions.set(clientConversationKey(client.id, secondConversation), secondSession);
gateway.relayEvent(client, firstConversation, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'first response ' },
});
gateway.relayEvent(client, secondConversation, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'second response ' },
});
expect(firstSession.assistantText).toBe('first response ');
expect(secondSession.assistantText).toBe('second response ');
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: firstConversation,
text: 'first response ',
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: secondConversation,
text: 'second response ',
});
});
it('persists only redacted assistant content with classifications', (): void => { it('persists only redacted assistant content with classifications', (): void => {
const { gateway, brain } = buildGateway(); const { gateway, brain } = buildGateway();
const client = { const client = {
@@ -147,7 +197,8 @@ describe('ChatGateway redaction boundary', (): void => {
data: { user: { id: 'user-1' } }, data: { user: { id: 'user-1' } },
emit: vi.fn(), emit: vi.fn(),
}; };
gateway.clientSessions.set(client.id, { gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), {
clientId: client.id,
conversationId: CONVERSATION_ID, conversationId: CONVERSATION_ID,
cleanup: vi.fn(), cleanup: vi.fn(),
assistantText: CANARY, assistantText: CANARY,

View File

@@ -17,6 +17,7 @@ import {
parseDiscordInteractionBindings, parseDiscordInteractionBindings,
resolveDiscordInteractionActorId, resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding, resolveDiscordInteractionBinding,
type DiscordAttachment,
type DiscordIngressEnvelope, type DiscordIngressEnvelope,
type DiscordIngressPayload, type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin'; } from '@mosaicstack/discord-plugin';
@@ -30,6 +31,7 @@ import type {
SystemReloadPayload, SystemReloadPayload,
RoutingDecisionInfo, RoutingDecisionInfo,
AbortPayload, AbortPayload,
ChannelAttachmentDto,
} from '@mosaicstack/types'; } from '@mosaicstack/types';
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
import { import {
@@ -56,6 +58,7 @@ import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
/** Per-client state tracking streaming accumulation for persistence. */ /** Per-client state tracking streaming accumulation for persistence. */
interface ClientSession { interface ClientSession {
clientId: string;
conversationId: string; conversationId: string;
cleanup: () => void; cleanup: () => void;
/** Accumulated assistant response text for the current turn. */ /** Accumulated assistant response text for the current turn. */
@@ -76,6 +79,68 @@ interface ClientSession {
*/ */
const modelOverrides = new Map<string, string>(); const modelOverrides = new Map<string, string>();
const MAX_REDACTION_BUFFER_LENGTH = 8_192; const MAX_REDACTION_BUFFER_LENGTH = 8_192;
const MAX_CHANNEL_ATTACHMENTS = 10;
const MAX_ATTACHMENT_METADATA_BYTES = 16_384;
const MAX_ATTACHMENT_ID_LENGTH = 128;
const MAX_ATTACHMENT_NAME_LENGTH = 255;
const MAX_ATTACHMENT_URL_LENGTH = 2_048;
const MAX_ATTACHMENT_MIME_LENGTH = 255;
function isSafeAttachmentUrl(value: string): boolean {
if (value.length === 0 || value.length > MAX_ATTACHMENT_URL_LENGTH) return false;
try {
const url = new URL(value);
return (
url.protocol === 'https:' &&
!url.username &&
!url.password &&
!url.hash &&
url.search.length === 0
);
} catch {
return false;
}
}
function hasValidAttachmentBounds(value: {
id: string;
name: string;
url: string;
sizeBytes?: number;
}): boolean {
return (
value.id.length > 0 &&
value.id.length <= MAX_ATTACHMENT_ID_LENGTH &&
value.name.length > 0 &&
value.name.length <= MAX_ATTACHMENT_NAME_LENGTH &&
isSafeAttachmentUrl(value.url) &&
(value.sizeBytes === undefined || (Number.isFinite(value.sizeBytes) && value.sizeBytes >= 0))
);
}
function isDiscordAttachment(value: unknown): value is DiscordAttachment {
if (typeof value !== 'object' || value === null) return false;
const attachment = value as Partial<DiscordAttachment>;
return (
typeof attachment.id === 'string' &&
typeof attachment.name === 'string' &&
typeof attachment.url === 'string' &&
(attachment.contentType === null ||
(typeof attachment.contentType === 'string' &&
attachment.contentType.length <= MAX_ATTACHMENT_MIME_LENGTH)) &&
(attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') &&
hasValidAttachmentBounds(attachment as DiscordAttachment)
);
}
function hasValidAttachmentArray(value: unknown, guard: (attachment: unknown) => boolean): boolean {
return (
Array.isArray(value) &&
value.length <= MAX_CHANNEL_ATTACHMENTS &&
JSON.stringify(value).length <= MAX_ATTACHMENT_METADATA_BYTES &&
value.every(guard)
);
}
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
if (typeof value !== 'object' || value === null) return false; if (typeof value !== 'object' || value === null) return false;
@@ -88,23 +153,49 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo
return false; return false;
} }
const payload = envelope.payload as Record<string, unknown>; const payload = envelope.payload as Record<string, unknown>;
return [ return (
payload['correlationId'], [
payload['messageId'], payload['correlationId'],
payload['guildId'], payload['messageId'],
payload['channelId'], payload['guildId'],
payload['userId'], payload['channelId'],
payload['conversationId'], payload['userId'],
payload['content'], payload['conversationId'],
].every((field: unknown): boolean => typeof field === 'string'); payload['content'],
].every((field: unknown): boolean => typeof field === 'string') &&
(payload['threadId'] === undefined || typeof payload['threadId'] === 'string') &&
(payload['attachments'] === undefined ||
hasValidAttachmentArray(payload['attachments'], isDiscordAttachment))
);
}
function isChannelAttachment(value: unknown): value is ChannelAttachmentDto {
if (typeof value !== 'object' || value === null) return false;
const attachment = value as Partial<ChannelAttachmentDto>;
return (
typeof attachment.id === 'string' &&
typeof attachment.name === 'string' &&
typeof attachment.url === 'string' &&
(attachment.mimeType === null ||
(typeof attachment.mimeType === 'string' &&
attachment.mimeType.length <= MAX_ATTACHMENT_MIME_LENGTH)) &&
(attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') &&
hasValidAttachmentBounds(attachment as ChannelAttachmentDto)
);
} }
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto { function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
if (typeof value !== 'object' || value === null) return false; if (typeof value !== 'object' || value === null) return false;
const payload = value as { content?: unknown; conversationId?: unknown }; const payload = value as {
content?: unknown;
conversationId?: unknown;
attachments?: unknown;
};
return ( return (
typeof payload.content === 'string' && typeof payload.content === 'string' &&
(payload.conversationId === undefined || typeof payload.conversationId === 'string') (payload.conversationId === undefined || typeof payload.conversationId === 'string') &&
(payload.attachments === undefined ||
hasValidAttachmentArray(payload.attachments, isChannelAttachment))
); );
} }
@@ -174,20 +265,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
handleDisconnect(client: Socket): void { handleDisconnect(client: Socket): void {
this.logger.log(`Client disconnected: ${client.id}`); this.logger.log(`Client disconnected: ${client.id}`);
const session = this.clientSessions.get(client.id); for (const [key, session] of this.clientSessions) {
if (session) { if (session.clientId !== client.id) continue;
session.cleanup(); session.cleanup();
this.agentService.removeChannel( this.agentService.removeChannel(
session.conversationId, session.conversationId,
`websocket:${client.id}`, `websocket:${client.id}`,
session.scope, session.scope,
); );
this.clientSessions.delete(client.id); this.clientSessions.delete(key);
this.textEgressBuffers.delete(key);
this.thinkingEgressBuffers.delete(key);
this.overflowedEgress.delete(`${key}:agent:text`);
this.overflowedEgress.delete(`${key}:agent:thinking`);
} }
this.textEgressBuffers.delete(client.id); }
this.thinkingEgressBuffers.delete(client.id);
this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); private clientConversationKey(client: Pick<Socket, 'id'>, conversationId: string): string {
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); return `${client.id}\u0000${conversationId}`;
} }
private getClientScope(client: Socket): ActorTenantScope | null { private getClientScope(client: Socket): ActorTenantScope | null {
@@ -218,7 +313,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
discordIngress = this.resolveDiscordIngress(client, rawData); discordIngress = this.resolveDiscordIngress(client, rawData);
if (!discordIngress) return; if (!discordIngress) return;
data = { conversationId: discordIngress.conversationId, content: discordIngress.content }; data = {
conversationId: discordIngress.conversationId,
content: discordIngress.content,
...(discordIngress.attachments
? {
attachments: discordIngress.attachments.map(
(attachment): ChannelAttachmentDto => ({
id: attachment.id,
name: attachment.name,
url: attachment.url,
mimeType: attachment.contentType,
...(attachment.sizeBytes !== undefined
? { sizeBytes: attachment.sizeBytes }
: {}),
}),
),
}
: {}),
};
} else { } else {
if (!isChatSocketMessage(rawData)) { if (!isChatSocketMessage(rawData)) {
this.logger.warn(`Rejected malformed chat message from ${client.id}`); this.logger.warn(`Rejected malformed chat message from ${client.id}`);
@@ -227,6 +340,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
data = rawData; data = rawData;
} }
const conversationId = data.conversationId ?? uuid(); const conversationId = data.conversationId ?? uuid();
const clientConversationKey = this.clientConversationKey(client, conversationId);
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordIngress && !discordServiceUserId) { if (discordIngress && !discordServiceUserId) {
this.logger.warn( this.logger.warn(
@@ -281,7 +395,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
this.logger.log( this.logger.log(
`Using /model override "${modelOverride}" for conversation=${conversationId}`, `Using /model override "${modelOverride}" for conversation=${conversationId}`,
); );
} else if (!resolvedProvider && !resolvedModelId) { } else if (!resolvedProvider && !resolvedModelId && !discordIngress) {
// No explicit provider/model from client — use routing engine (M4-012) // No explicit provider/model from client — use routing engine (M4-012)
try { try {
const routingDecision = await this.routingEngine.resolve(data.content, userId); const routingDecision = await this.routingEngine.resolve(data.content, userId);
@@ -304,12 +418,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
} }
let resolvedAgentConfigId = data.agentId;
if (discordIngress) {
const binding = this.discordBindingFor(discordIngress, 'send');
const agentConfig = binding
? await this.brain.agents.findById(binding.agentConfigId)
: undefined;
if (!binding || !agentConfig || agentConfig.name !== binding.instanceId) {
throw new Error('Configured Discord logical agent is not provisioned');
}
resolvedAgentConfigId = agentConfig.id;
}
// M5-004: Use existingSessionId as sessionId when available (session reuse) // M5-004: Use existingSessionId as sessionId when available (session reuse)
const sessionIdToCreate = existingSessionId ?? conversationId; const sessionIdToCreate = existingSessionId ?? conversationId;
agentSession = await this.agentService.createSession(sessionIdToCreate, { agentSession = await this.agentService.createSession(sessionIdToCreate, {
provider: resolvedProvider, provider: resolvedProvider,
modelId: resolvedModelId, modelId: resolvedModelId,
agentConfigId: data.agentId, agentConfigId: resolvedAgentConfigId,
userId, userId,
tenantId: scope.tenantId, tenantId: scope.tenantId,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
@@ -360,6 +486,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
discordUserId: discordIngress?.userId, discordUserId: discordIngress?.userId,
} }
: {}), : {}),
...(data.attachments && data.attachments.length > 0
? {
channelAttachments: data.attachments.map(
(attachment): ChannelAttachmentDto => ({
...attachment,
name: redactSensitiveContent(attachment.name).content,
url: redactSensitiveContent(attachment.url).content,
}),
),
}
: {}),
classifications: redactSensitiveContent(data.content).classifications, classifications: redactSensitiveContent(data.content).classifications,
}, },
}, },
@@ -374,7 +511,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
// Always clean up previous listener to prevent leak // Always clean up previous listener to prevent leak
const existing = this.clientSessions.get(client.id); const existing = this.clientSessions.get(clientConversationKey);
if (existing) { if (existing) {
existing.cleanup(); existing.cleanup();
} }
@@ -389,10 +526,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
); );
// Preserve routing decision from the existing client session if we didn't get a new one // Preserve routing decision from the existing client session if we didn't get a new one
const prevClientSession = this.clientSessions.get(client.id); const prevClientSession = this.clientSessions.get(clientConversationKey);
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision; const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
this.clientSessions.set(client.id, { this.clientSessions.set(clientConversationKey, {
clientId: client.id,
conversationId, conversationId,
cleanup, cleanup,
assistantText: '', assistantText: '',
@@ -438,7 +576,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
// Dispatch to agent // Dispatch to agent
try { try {
await this.agentService.prompt(conversationId, data.content, scope); await this.agentService.prompt(conversationId, data.content, scope, data.attachments);
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`, `Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
@@ -645,9 +783,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}; };
// Emit to all clients currently subscribed to this conversation // Emit to all clients currently subscribed to this conversation
for (const [clientId, session] of this.clientSessions) { for (const session of this.clientSessions.values()) {
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) { if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
const socket = this.server.sockets.sockets.get(clientId); const socket = this.server.sockets.sockets.get(session.clientId);
if (socket?.connected) { if (socket?.connected) {
socket.emit('session:info', payload); socket.emit('session:info', payload);
} }
@@ -677,16 +815,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
!this.durableSessions !this.durableSessions
) )
return; return;
const binding = resolveDiscordInteractionBinding( const binding = this.discordBindingFor(ingress, 'approve');
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
ingress.guildId,
ingress.channelId,
ingress.userId,
'approve',
);
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim(); const agentName = binding?.instanceId;
if (!actorId || !agentName || binding.instanceId !== agentName) { if (!actorId || !agentName) {
this.logger.warn( this.logger.warn(
`Rejected Discord approval without a matching runtime agent from ${client.id}`, `Rejected Discord approval without a matching runtime agent from ${client.id}`,
); );
@@ -774,13 +906,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions) if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
return; return;
const binding = resolveDiscordInteractionBinding( const binding = this.discordBindingFor(ingress, 'stop');
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
ingress.guildId,
ingress.channelId,
ingress.userId,
'stop',
);
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
if (!actorId) return; if (!actorId) return;
@@ -854,17 +980,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return null; return null;
} }
try { try {
const binding = resolveDiscordInteractionBinding( const binding = this.discordBindingFor(payload, operation);
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
payload.guildId,
payload.channelId,
payload.userId,
operation,
);
if (!binding) { if (!binding) {
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
return null; return null;
} }
const expectedConversationId = `${binding.instanceId}:discord:${payload.threadId ?? payload.channelId}`;
if (payload.conversationId !== expectedConversationId) {
this.logger.warn(
`Rejected Discord ingress for a different logical agent from ${client.id}`,
);
return null;
}
} catch { } catch {
this.logger.warn( this.logger.warn(
`Rejected Discord ingress without valid binding configuration from ${client.id}`, `Rejected Discord ingress without valid binding configuration from ${client.id}`,
@@ -880,6 +1007,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return payload; return payload;
} }
private discordBindingFor(
payload: DiscordIngressPayload,
operation: 'send' | 'approve' | 'stop',
) {
return resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
payload.guildId,
payload.channelId,
payload.userId,
operation,
);
}
private readDiscordAllowlist(name: string): string[] { private readDiscordAllowlist(name: string): string[] {
return (process.env[name] ?? '') return (process.env[name] ?? '')
.split(',') .split(',')
@@ -958,11 +1098,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const messages = await this.brain.conversations.findMessages(conversationId, userId); const messages = await this.brain.conversations.findMessages(conversationId, userId);
if (messages.length === 0) return []; if (messages.length === 0) return [];
return messages.map((msg) => ({ return messages.map((msg) => {
role: msg.role as 'user' | 'assistant' | 'system', const attachments = this.persistedChannelAttachments(msg.metadata);
content: msg.content, return {
createdAt: msg.createdAt, role: msg.role as 'user' | 'assistant' | 'system',
})); content: msg.content,
createdAt: msg.createdAt,
...(attachments ? { attachments } : {}),
};
});
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Failed to load conversation history for conversation=${conversationId}`, `Failed to load conversation history for conversation=${conversationId}`,
@@ -972,6 +1116,14 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
} }
private persistedChannelAttachments(metadata: unknown): readonly ChannelAttachmentDto[] | null {
if (typeof metadata !== 'object' || metadata === null) return null;
const attachments = (metadata as { channelAttachments?: unknown }).channelAttachments;
return hasValidAttachmentArray(attachments, isChannelAttachment)
? (attachments as readonly ChannelAttachmentDto[])
: null;
}
private appendAndFlushRedactedEgress( private appendAndFlushRedactedEgress(
client: Socket, client: Socket,
conversationId: string, conversationId: string,
@@ -979,18 +1131,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
buffers: Map<string, string>, buffers: Map<string, string>,
delta: string, delta: string,
): void { ): void {
const key = this.egressKey(client, eventName); const sessionKey = this.clientConversationKey(client, conversationId);
const key = this.egressKey(client, conversationId, eventName);
if (this.overflowedEgress.has(key)) return; if (this.overflowedEgress.has(key)) return;
const buffered = `${buffers.get(client.id) ?? ''}${delta}`; const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`;
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) { if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
buffers.delete(client.id); buffers.delete(sessionKey);
this.overflowedEgress.add(key); this.overflowedEgress.add(key);
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' }); client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
return; return;
} }
buffers.set(client.id, buffered); buffers.set(sessionKey, buffered);
this.flushRedactedEgress(client, conversationId, eventName, buffers, false); this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
} }
@@ -1006,21 +1159,22 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
buffers: Map<string, string>, buffers: Map<string, string>,
final: boolean, final: boolean,
): void { ): void {
const key = this.egressKey(client, eventName); const sessionKey = this.clientConversationKey(client, conversationId);
const key = this.egressKey(client, conversationId, eventName);
if (this.overflowedEgress.has(key)) { if (this.overflowedEgress.has(key)) {
if (final) this.overflowedEgress.delete(key); if (final) this.overflowedEgress.delete(key);
return; return;
} }
const buffered = buffers.get(client.id) ?? ''; const buffered = buffers.get(sessionKey) ?? '';
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
const released = buffered.slice(0, releaseLength); const released = buffered.slice(0, releaseLength);
const pending = buffered.slice(releaseLength); const pending = buffered.slice(releaseLength);
if (pending) { if (pending) {
buffers.set(client.id, pending); buffers.set(sessionKey, pending);
} else { } else {
buffers.delete(client.id); buffers.delete(sessionKey);
} }
if (released) { if (released) {
@@ -1089,8 +1243,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return retainedFrom; return retainedFrom;
} }
private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string { private egressKey(
return `${client.id}:${eventName}`; client: Socket,
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
): string {
return `${this.clientConversationKey(client, conversationId)}:${eventName}`;
} }
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
@@ -1101,26 +1259,27 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return; return;
} }
const sessionKey = this.clientConversationKey(client, conversationId);
switch (event.type) { switch (event.type) {
case 'agent_start': { case 'agent_start': {
// Reset accumulation buffers for the new turn // Reset accumulation buffers for the new turn
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(sessionKey);
if (cs) { if (cs) {
cs.assistantText = ''; cs.assistantText = '';
cs.toolCalls = []; cs.toolCalls = [];
cs.pendingToolCalls.clear(); cs.pendingToolCalls.clear();
} }
this.textEgressBuffers.set(client.id, ''); this.textEgressBuffers.set(sessionKey, '');
this.thinkingEgressBuffers.set(client.id, ''); this.thinkingEgressBuffers.set(sessionKey, '');
this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:thinking'));
client.emit('agent:start', { conversationId }); client.emit('agent:start', { conversationId });
break; break;
} }
case 'agent_end': { case 'agent_end': {
// Gather usage stats from the Pi session // Gather usage stats from the Pi session
const activeClientSession = this.clientSessions.get(client.id); const activeClientSession = this.clientSessions.get(sessionKey);
const agentSession = activeClientSession const agentSession = activeClientSession
? this.agentService.getSession(conversationId, activeClientSession.scope) ? this.agentService.getSession(conversationId, activeClientSession.scope)
: undefined; : undefined;
@@ -1173,7 +1332,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
// Persist the assistant message with metadata // Persist the assistant message with metadata
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(sessionKey);
const userId = (client.data.user as { id: string } | undefined)?.id; const userId = (client.data.user as { id: string } | undefined)?.id;
if (cs && userId && cs.assistantText.trim().length > 0) { if (cs && userId && cs.assistantText.trim().length > 0) {
const metadata: Record<string, unknown> = { const metadata: Record<string, unknown> = {
@@ -1225,7 +1384,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const assistantEvent = event.assistantMessageEvent; const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type === 'text_delta') { if (assistantEvent.type === 'text_delta') {
// Keep raw stream material in memory only; persist and emit only redacted text. // Keep raw stream material in memory only; persist and emit only redacted text.
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(sessionKey);
if (cs) { if (cs) {
cs.assistantText += assistantEvent.delta; cs.assistantText += assistantEvent.delta;
} }
@@ -1250,7 +1409,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'tool_execution_start': { case 'tool_execution_start': {
// Track pending tool call for later recording // Track pending tool call for later recording
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(sessionKey);
if (cs) { if (cs) {
cs.pendingToolCalls.set(event.toolCallId, { cs.pendingToolCalls.set(event.toolCallId, {
toolName: event.toolName, toolName: event.toolName,
@@ -1267,7 +1426,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'tool_execution_end': { case 'tool_execution_end': {
// Finalise tool call record // Finalise tool call record
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(sessionKey);
if (cs) { if (cs) {
const pending = cs.pendingToolCalls.get(event.toolCallId); const pending = cs.pendingToolCalls.get(event.toolCallId);
cs.toolCalls.push({ cs.toolCalls.push({

View File

@@ -24,6 +24,7 @@ const ENV_KEYS = [
'DISCORD_ALLOWED_CHANNEL_IDS', 'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS', 'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME', 'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CONFIG_ID',
] as const; ] as const;
const savedEnv = new Map<string, string | undefined>(); const savedEnv = new Map<string, string | undefined>();
@@ -33,12 +34,14 @@ function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service'; process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord'; process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
process.env['MOSAIC_AGENT_NAME'] = 'Nova'; process.env['MOSAIC_AGENT_NAME'] = 'Nova';
process.env['MOSAIC_AGENT_CONFIG_ID'] = 'agent-config-nova';
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001'; process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001'; process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { pairedUsers: {
@@ -141,7 +144,7 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
userId: 'user-001', userId: 'user-001',
conversationId: 'discord-channel-001', conversationId: 'Nova:discord:channel-001',
content: 'hello Tess', content: 'hello Tess',
...overrides, ...overrides,
}; };
@@ -153,6 +156,7 @@ describe('Discord ingress security', () => {
JSON.stringify([ JSON.stringify([
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { 'user-001': 'admin' }, pairedUsers: { 'user-001': 'admin' },
@@ -170,6 +174,7 @@ describe('Discord ingress security', () => {
[ [
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
@@ -307,41 +312,46 @@ describe('Discord ingress security', () => {
); );
}); });
it.each([ it('rejects approval when the durable session targets a different logical agent', async () => {
[ configureDiscordEnv();
'binding', const { gateway, client, durable } = discordGateway('admin');
() => { durable.getSnapshot.mockResolvedValueOnce({
process.env['MOSAIC_AGENT_NAME'] = 'Other'; identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}, });
],
[
'durable session',
(durable: { getSnapshot: ReturnType<typeof vi.fn> }) => {
durable.getSnapshot.mockResolvedValueOnce({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
},
],
])(
'rejects approval when the %s targets a different runtime agent',
async (_source, configure) => {
configureDiscordEnv();
const { gateway, client, durable } = discordGateway('admin');
configure(durable);
await gateway.handleDiscordApproval( await gateway.handleDiscordApproval(
client as never, client as never,
ingressEnvelope('/approve', 'mismatched-agent-approve'), ingressEnvelope('/approve', 'mismatched-agent-approve'),
); );
expect(client.emit).toHaveBeenCalledWith('discord:approval', { expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001', correlationId: 'correlation-001',
success: false, success: false,
approvalId: undefined, approvalId: undefined,
expiresAt: undefined, expiresAt: undefined,
}); });
}, });
);
it('rejects privileged envelopes with a forged current conversation route', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'forged-approval-route', {
conversationId: 'Nova:discord:other-channel',
}),
);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope('/stop forged', 'forged-stop-route', {
conversationId: 'Nova:discord:other-channel',
}),
);
expect(client.emit).not.toHaveBeenCalledWith('discord:approval', expect.anything());
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
});
it('rejects unpaired and non-admin Discord users for approval and stop', async () => { it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
configureDiscordEnv(); configureDiscordEnv();
@@ -398,6 +408,267 @@ describe('Discord ingress security', () => {
]); ]);
}); });
it.each([
'https://user:password@cdn.example.test/diagram.png',
'https://cdn.example.test/diagram.png?token=secret',
'https://cdn.example.test/diagram.png?X-Amz-Signature=secret',
'https://cdn.example.test/diagram.png?auth=secret',
'https://cdn.example.test/diagram.png?hm=secret',
])('rejects credential-bearing attachment URLs before gateway dispatch', async (url) => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleMessage(
client as never,
ingressEnvelope('', `credential-url-${url.length}`, {
conversationId: 'Nova:discord:channel-001',
attachments: [
{ id: 'attachment-credential', name: 'diagram.png', url, contentType: 'image/png' },
],
}),
);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
});
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
configureDiscordEnv();
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
{
instanceId: 'Orion',
agentConfigId: 'agent-config-orion',
guildId: 'guild-001',
channelId: 'channel-002',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
const brain = {
agents: {
findById: vi.fn((id: string) =>
Promise.resolve({
id,
name: id === 'agent-config-orion' ? 'Orion' : 'Nova',
}),
),
},
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const routingEngine = { resolve: vi.fn() };
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
routingEngine as never,
);
const client = {
id: 'discord-client-new-session',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('start configured session', 'configured-session-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
await gateway.handleMessage(
client as never,
ingressEnvelope('start second configured session', 'configured-session-002', {
channelId: 'channel-002',
conversationId: 'Orion:discord:channel-002',
}),
);
expect(createSession).toHaveBeenCalledWith(
'Nova:discord:channel-001',
expect.objectContaining({
agentConfigId: 'agent-config-nova',
userId: 'discord-service',
tenantId: 'tenant-discord',
}),
);
expect(createSession).toHaveBeenCalledWith(
'Orion:discord:channel-002',
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
);
expect(routingEngine.resolve).not.toHaveBeenCalled();
});
it('retains validated persisted attachments in resumed conversation history', async () => {
const attachment = {
id: 'attachment-history',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
mimeType: 'image/png',
sizeBytes: 4_096,
};
const gateway = new ChatGateway(
{} as never,
{} as never,
{
conversations: {
findMessages: vi.fn().mockResolvedValue([
{
role: 'user',
content: '',
createdAt: new Date('2026-07-14T12:00:00.000Z'),
metadata: { channelAttachments: [attachment] },
},
]),
},
} as never,
{} as never,
{} as never,
{} as never,
) as unknown as {
loadConversationHistory(
conversationId: string,
userId: string,
): Promise<Array<{ attachments?: readonly (typeof attachment)[] }>>;
};
await expect(
gateway.loadConversationHistory('Nova:discord:channel-001', 'discord-service'),
).resolves.toEqual([expect.objectContaining({ attachments: [attachment] })]);
});
it('rejects malformed signed attachment payloads before gateway dispatch', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
const malformedPayload: Record<string, unknown> = {
...createPayload({
messageId: 'malformed-attachments-001',
conversationId: 'Nova:discord:channel-001',
}),
attachments: { id: 'not-an-array' },
};
const envelope = createDiscordIngressEnvelope(
malformedPayload as unknown as DiscordIngressPayload,
SERVICE_TOKEN,
);
await gateway.handleMessage(client as never, envelope);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
});
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
configureDiscordEnv();
const prompt = vi.fn().mockResolvedValue(undefined);
const addMessage = vi.fn().mockResolvedValue(undefined);
const session = {
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(session),
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt,
};
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
const client = {
id: 'discord-client-001',
data: { discordService: true },
emit: vi.fn(),
};
const attachment = {
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
contentType: 'image/png',
sizeBytes: 4_096,
};
await gateway.handleMessage(
client as never,
ingressEnvelope('', 'attachment-message-001', {
conversationId: 'Nova:discord:channel-001',
attachments: [attachment],
}),
);
const expectedAttachment = {
id: attachment.id,
name: attachment.name,
url: attachment.url,
mimeType: attachment.contentType,
sizeBytes: attachment.sizeBytes,
};
expect(prompt).toHaveBeenCalledWith(
'Nova:discord:channel-001',
'',
{ userId: 'discord-service', tenantId: 'tenant-discord' },
[expectedAttachment],
);
expect(addMessage).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: 'Nova:discord:channel-001',
metadata: expect.objectContaining({ channelAttachments: [expectedAttachment] }),
}),
'discord-service',
);
});
it('accepts a thread message through its allowed bound parent channel', () => { it('accepts a thread message through its allowed bound parent channel', () => {
const emitted = vi.fn(); const emitted = vi.fn();
const plugin = new DiscordPlugin({ const plugin = new DiscordPlugin({
@@ -410,6 +681,7 @@ describe('Discord ingress security', () => {
interactionBindings: [ interactionBindings: [
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },

View File

@@ -61,6 +61,16 @@ function requiredDiscordAllowlist(name: string): string[] {
return value; return value;
} }
function optionalPositiveInteger(name: string): number | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer when configured`);
}
return value;
}
function createPluginRegistry(): IChannelPlugin[] { function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = []; const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN']; const discordToken = process.env['DISCORD_BOT_TOKEN'];
@@ -82,6 +92,10 @@ function createPluginRegistry(): IChannelPlugin[] {
guildId: discordGuildId, guildId: discordGuildId,
gatewayUrl: discordGatewayUrl, gatewayUrl: discordGatewayUrl,
serviceToken: discordServiceToken, serviceToken: discordServiceToken,
messageRateLimitPerMinute: optionalPositiveInteger(
'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE',
),
threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'),
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),

View File

@@ -79,6 +79,52 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
--- ---
## Fleet Declarative Configuration Management Workstream (FCM, #758)
### Problem and objective
The local Mosaic fleet has a roster, generated agent environment files, user-systemd units, tmux
sessions, heartbeat files, examples, profiles, and separate gateway-backed agent records. These
planes have drifted and are not one safe operator lifecycle. The objective is one **local fleet
roster** as the desired-state SSOT, with generated environment, systemd, tmux, and heartbeat
artifacts as rebuildable projections; it does not merge the local fleet control plane with the
gateway-backed agent catalog.
### Normative requirements
| ID | Requirement |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
| `FCM-REQ-09` | M1M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
### Acceptance criteria
1. `AC-FCM-01`: A valid local v2 roster can be parsed from YAML or JSON, validated structurally and semantically through the shared resolver, and rendered canonically; invalid fields, duplicate names, unresolved classes, unsupported runtime/model combinations, socket ambiguity, and incompatible options fail closed.
2. `AC-FCM-02`: `plan` reports deterministic desired-versus-observed differences for roster, generated environment, systemd enablement, tmux/session, heartbeat, installed-asset revision, and provable orphans without mutation; `apply --check` reports drift without mutation.
3. `AC-FCM-03`: Local create/update/delete is generation-guarded, atomic, idempotent, and safe by default; it permits supported runtime/model/harness/effort/workdir/role changes without direct editing of generated environment files and does not start a newly created agent unless explicitly persisted.
4. `AC-FCM-04`: The generated-env/local-override launch chain rejects generated-key shadowing, arbitrary command override, unknown keys, shell evaluation, and sensitive-value diagnostics before any agent starts; known-safe legacy input is regenerated or strictly relocated, and forbidden input is quarantined.
5. `AC-FCM-05`: Local lifecycle reconciliation implements the persisted/transient start-stop rules, exact default/named tmux socket targeting, systemd/tmux status, stale generated state, unmanaged-session reporting, and rollback without surprise restarts or fuzzy destructive targeting.
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
### M0 implementation gate
No source, schema, role, example, profile, systemd, or live-fleet change is authorized before M0
lands. M0 consists only of these normative requirements, the complete task DAG, the scoped
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
---
## Tess Interaction Agent Workstream (TESS) ## Tess Interaction Agent Workstream (TESS)
### Problem and Objective ### Problem and Objective
@@ -175,6 +221,69 @@ Delivery uses five gated milestones: runtime contracts/security; Pi service/stat
--- ---
## Official Channel Plugin Workstream (#756)
### Problem and Objective
The Discord plugin currently couples Discord event handling, gateway bridging, and reply routing in one implementation and activates only on mentions. Mosaic needs an official channel adapter that behaves the same no matter whether the bound logical agent currently runs through Claude, Codex, Pi, OpenCode, or a future harness. The Discord connection and conversation address must remain stable while the gateway changes the runtime provider behind that logical session.
The objective is to make Discord the first implementation of a transport-neutral official channel contract, with explicit authorization and deterministic channel/thread routing that future Matrix, Slack, and other adapters can share.
### Scope
#### In Scope
1. `CHN-001`: Transport-neutral channel adapter, route, message, attachment, authorization-principal, response-target, and health contracts in `@mosaicstack/types`, including trusted per-binding logical-agent configuration selection.
2. `CHN-002`: Stable channel conversation addresses based on logical agent plus channel/thread identity; harness, model, and runtime-provider IDs are forbidden from channel session keys.
3. `DSC-001`: An authorized untagged message in a configured agent-bound channel routes to the agent and receives its response in that channel.
4. `DSC-002`: A bot mention in a configured parent channel creates a Discord thread, or reuses the thread already attached to that same native message; the mentioned turn and subsequent thread turns route and respond in that thread.
5. `DSC-003`: A message already inside an authorized thread inherits authorization from its configured parent and never attempts a nested thread.
6. `DSC-004`: Guild, parent channel, user, pairing, and role authorization remains default-deny before thread creation or gateway dispatch.
7. `DSC-005`: Discord service authentication, HMAC envelope integrity, replay protection, attachments, approvals, response chunking, and correlation behavior remain intact.
8. `DSC-006`: The Discord adapter exposes lifecycle and health behavior through the shared channel contract without importing a harness SDK.
#### Out of Scope
1. The logical-agent lease, fencing epoch, execution grant, checkpoint, or cross-harness takeover implementation tracked by #754/#755.
2. Dynamic Discord authorization administration in the web UI.
3. Multi-guild tenant isolation, DMs, slash commands, voice, reactions, or production bot deployment.
4. Implementing Matrix or Slack adapters in this slice.
### Non-Functional Requirements
1. **Security:** no thread or dispatch side effect occurs until guild, parent channel, user, pairing, role, and bounded per-user/channel rate checks pass; attachment metadata is shape- and size-bounded; credentials never enter source, messages, session keys, or logs.
2. **Portability:** channel contracts and stable conversation IDs contain no Claude, Codex, Pi, OpenCode, model, process, or provider-specific field; each configuration-owned binding selects its trusted logical agent without changing the channel identity.
3. **Reliability:** repeated messages for one channel/thread resolve the same conversation handle; reconnecting the adapter does not require a harness-specific rebinding.
4. **Maintainability:** Discord-specific API translation stays in the Discord package; gateway and future adapters depend on transport-neutral contracts.
5. **Observability:** thread creation or routing failure is reported without message content or credential material.
### Acceptance Criteria
1. `AC-CHN-01`: Contract and behavior tests prove the plugin route contains only logical agent plus channel/thread identity and produces the same stable conversation handle regardless of underlying harness selection.
2. `AC-CHN-02`: A mentioned authorized parent-channel message creates a thread (or reuses its already-attached thread), dispatches to the thread conversation, and targets the response to that thread.
3. `AC-CHN-03`: An untagged authorized parent-channel message dispatches to the parent conversation and targets the response to the parent channel.
4. `AC-CHN-04`: Untagged follow-ups inside an authorized thread dispatch and respond in that same thread without creating a nested thread.
5. `AC-CHN-05`: Unauthorized guilds, channels, users, unpaired users, insufficient roles, and rate-limited senders produce no thread and no gateway dispatch.
6. `AC-CHN-06`: Shared channel contracts are exported from `@mosaicstack/types`, Discord implements the lifecycle/health seam, and no harness SDK is imported by the plugin.
7. `AC-CHN-07`: Focused routing/auth tests, package tests, typecheck, lint, formatting, coverage, independent code/security review, and terminal-green CI pass.
### Constraints, Risks, and Assumptions
- Dependency: Mosaic gateway remains the policy, durable-session, audit, and runtime-provider boundary.
- Constraint: This work must not modify orchestrator-to-Pi migration or #754/#755 lease/fencing files.
- Risk: accepting untagged messages could create noisy or unintended agent input. Mitigation: only explicitly configured channels and paired, role-authorized users are accepted, with bounded per-user/channel message and thread rates.
- Risk: Discord thread creation can fail because of channel permissions, archived state, or API rate limits. Mitigation: fail without dispatching a turn whose response destination cannot be honored, and emit sanitized diagnostics.
- `ASSUMPTION:` Configured channels are dedicated agent interaction surfaces, so authorized untagged human messages are intentional agent input.
- `ASSUMPTION:` Mention in a parent channel selects a public thread; messages already in a thread remain there because Discord has no nested threads.
- `ASSUMPTION:` One Discord bot may serve multiple configuration-owned logical-agent bindings.
- `ASSUMPTION:` Static allowlists and paired-user roles are the authorization administration surface for this slice.
### Testing and Delivery Intent
Use TDD for remote-ingress routing and permission boundaries. Required evidence includes parent-channel mention, untagged parent message, existing-thread follow-up, existing-thread mention, thread reuse, unauthorized side-effect denial, stable harness-neutral conversation identity, adapter health, and regression coverage for signed envelopes and approvals. Deliver through issue #756, a reviewed squash PR to `main`, terminal-green CI, and issue closure.
---
## Architecture ## Architecture
### High-Level System Diagram ### High-Level System Diagram
@@ -529,7 +638,8 @@ Discord remote control channel. Architecture inspired by OpenClaw (https://githu
- Single-guild binding only (v0.1.0) — prevents data leaks between servers - Single-guild binding only (v0.1.0) — prevents data leaks between servers
- Receives Discord messages, dispatches through gateway routing - Receives Discord messages, dispatches through gateway routing
- Streams agent responses back to Discord (chunked for 2000-char limit) - Streams agent responses back to Discord (chunked for 2000-char limit)
- Supports mention-based activation, thread management for multi-turn - Routes authorized untagged messages in-channel; mentions create threads (or reuse the same message's attached thread) for multi-turn topics
- Uses stable logical-agent/channel conversation addresses independent of the active harness/provider
- Bot pairing and permission management (Discord user → Mosaic user mapping) - Bot pairing and permission management (Discord user → Mosaic user mapping)
- DM support for private conversations - DM support for private conversations
@@ -702,10 +812,12 @@ Telegram remote control channel.
### FR-9: Remote Control — Discord ### FR-9: Remote Control — Discord
- Discord bot that connects to the gateway - Discord bot that connects to the gateway through a transport-neutral channel adapter contract
- Mention-based activation in channels - Authorized messages in configured agent-bound channels work without a mention and respond in-channel
- Mentions in parent channels create threads, or reuse a thread already attached to that same native message, for multi-turn conversations
- Messages already in a thread remain there without requiring repeated mentions
- Stable logical-agent/channel conversation identity survives underlying harness/provider changes
- DM support for private conversations - DM support for private conversations
- Thread creation for multi-turn conversations
- Chunked message delivery (Discord 2000-char limit) - Chunked message delivery (Discord 2000-char limit)
- Bot configuration via web dashboard - Bot configuration via web dashboard
- Permission management (which Discord users/roles can interact) - Permission management (which Discord users/roles can interact)
@@ -889,10 +1001,13 @@ Telegram remote control channel.
### AC-3: Discord Remote Control ### AC-3: Discord Remote Control
- [ ] Discord bot connects and responds to mentions - [ ] Discord bot connects through the harness-neutral channel contract
- [ ] Messages route through gateway to agent pool - [ ] Authorized untagged channel messages route through the gateway and respond in-channel
- [ ] Mentioned parent-channel messages create a thread (or reuse their already-attached thread) and respond there
- [ ] Existing-thread follow-ups stay in the thread without repeated mentions
- [ ] Channel/session identity remains stable while the underlying harness/provider changes
- [ ] Responses stream back to Discord (chunked) - [ ] Responses stream back to Discord (chunked)
- [ ] Thread creation for multi-turn conversations - [ ] Unauthorized guilds, channels, users, pairings, and roles create no thread and dispatch no message
### AC-4: Gateway Orchestration ### AC-4: Gateway Orchestration
@@ -1091,7 +1206,7 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
6. ASSUMPTION: **Log summarization uses Haiku-tier LLM by default, configurable.** Haiku is well-suited for summarization (compression, not generation — source material is in context). Guardrails: structured output via Zod schema (force extraction of decisions/tools/outcomes/errors as discrete fields), chunked per-session processing (no bulk conflation), extraction-focused prompts. Raw logs stay in hot tier (7 days) as safety net. Users can override the summarization model via routing engine config if they want higher fidelity. Rationale: Haiku is 10-20x cheaper than Sonnet; log summarization runs on schedule against large volumes where cost matters. 6. ASSUMPTION: **Log summarization uses Haiku-tier LLM by default, configurable.** Haiku is well-suited for summarization (compression, not generation — source material is in context). Guardrails: structured output via Zod schema (force extraction of decisions/tools/outcomes/errors as discrete fields), chunked per-session processing (no bulk conflation), extraction-focused prompts. Raw logs stay in hot tier (7 days) as safety net. Users can override the summarization model via routing engine config if they want higher fidelity. Rationale: Haiku is 10-20x cheaper than Sonnet; log summarization runs on schedule against large volumes where cost matters.
7. ASSUMPTION: **Discord plugin starts minimal and single-guild only**DM support, mention-based channel activation, thread management, chunked responses. Single guild binding to prevent data leaks between servers. Advanced features (voice, components, slash commands, multi-guild) are post-beta. Rationale: Proven pattern from OpenClaw; ship core interaction first; data isolation is non-negotiable. 7. ASSUMPTION: **Discord plugin starts minimal and single-guild only**explicitly configured agent-bound channels accept authorized untagged messages in-channel, while mentions create threads or reuse a thread already attached to that same native message; responses are chunked. Single guild binding prevents data leaks between servers. DM support, voice, components, slash commands, and multi-guild operation are post-beta. Rationale: Ship the requested core interaction model while preserving default-deny data isolation.
8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel. 8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel.

View File

@@ -1,5 +1,13 @@
# Documentation Sitemap # Documentation Sitemap
## Official channel plugins
- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts.
- [Discord administrator configuration](guides/admin-guide.md#discord-ingress-security) — secrets, allowlists, bindings, role policy, and thread permissions.
- [Discord user workflow](tess/USER-GUIDE.md#discord-conversations) — in-channel messages, mention-created threads, and runtime-transparent continuity.
- [Channel plugin authoring](tess/PLUGIN-GUIDE.md#official-channel-adapter-contract) — requirements for future Matrix, Slack, and other official adapters.
- [Discord package guide](../plugins/discord/README.md) — package behavior, configuration shape, and development commands.
## Native Kanban and canonical task SOT ## Native Kanban and canonical task SOT
- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0P3 requirements and acceptance criteria. - [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0P3 requirements and acceptance criteria.

View File

@@ -14,11 +14,12 @@
## Workstream Rollup ## Workstream Rollup
| id | status | workstream | progress | tasks file | notes | | id | status | workstream | progress | tasks file | notes |
| --- | ----------------- | ---------------------- | ---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------- | | --- | ----------------- | ------------------------------ | ---------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning | | W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning |
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | | W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges | | W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges |
| W4 | planning-complete | Fleet configuration management | 0 / 12 cards | This file (§ Fleet configuration management #758) | Issue #758; M0 docs gate defines the implementation DAG before any fleet mutation |
## Cross-Cutting Tracking ## Cross-Cutting Tracking
@@ -42,6 +43,30 @@ Active workstream is **W1 — Federation v1**. Workers should:
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task 2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
3. Follow per-task agent + tier guidance from the workstream manifest 3. Follow per-task agent + tier guidance from the workstream manifest
## Fleet configuration management (#758) — M0M5 implementation DAG
> **PRD:** [Fleet declarative configuration management](./PRD.md#fleet-declarative-configuration-management-workstream-fcm-758) · **M0 acceptance:** [docs IA checklist](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) · **baseline dispositions:** [legacy example/profile inventory](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md)
>
> Every row below is one independently reviewable card and **one PR**. `depends_on` is a
> hard DAG edge; no card may silently absorb another card's scope. All source cards require
> the repository quality gates, independent code and security review, terminal-green CI, and
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| FCM-M0-001 | in-progress | Publish normative PRD requirements/acceptance criteria, this M0M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | M0 exit: approved docs; every shipped example/profile/service preset classified; docs-only PR |
| FCM-M1-001 | not-started | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | codex | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | No lifecycle, remote, connector, secret, channel, or gateway work |
| FCM-M1-002 | not-started | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | codex | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Validator is certificate-only; merge-gate remains sole merge authority |
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only |
| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting |
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
## Thin-core prompt diet (#528) — feat/contract-thin-core ## Thin-core prompt diet (#528) — feat/contract-thin-core
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change). - Status: PR open, awaiting maintainer merge ratification (fleet-governing change).

View File

@@ -1,9 +1,9 @@
# Channel Protocol Architecture # Channel Protocol Architecture
**Status:** Draft **Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative
**Authors:** Mosaic Core Team **Authors:** Mosaic Core Team
**Last Updated:** 2026-03-22 **Last Updated:** 2026-07-14
**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) **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)
--- ---
@@ -11,93 +11,80 @@
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 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: The implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups:
1. `IChannelAdapter` — the interface each channel driver must implement. 1. `OfficialChannelAdapter` — transport lifecycle and connection health.
2. `ChannelMessage` — the canonical message format that flows through the system. 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. The rest of Mosaic works exclusively with `ChannelMessage` objects. 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: IChannelAdapter Interface ## M7-001: OfficialChannelAdapter Interface
```typescript ```typescript
interface IChannelAdapter { interface OfficialChannelAdapter {
/** /** Stable, lowercase adapter identifier such as "discord" or "matrix". */
* Stable, lowercase identifier for this channel (e.g. "matrix", "discord").
* Used as a namespace key in registry lookups and log metadata.
*/
readonly name: string; readonly name: string;
/** Establish both native-channel and gateway connections. */
/** start(): Promise<void>;
* Establish a connection to the external channel backend. /** Gracefully close connections and release resources. */
* Called once at application startup. Must be idempotent (safe to call stop(): Promise<void>;
* when already connected). /** Best-effort health; ordinary disconnection is a result, not an exception. */
*/ health(): Promise<{
connect(): Promise<void>; status: 'connected' | 'degraded' | 'disconnected';
detail?: string;
/** }>;
* 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>;
} }
``` ```
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 ### 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). 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 ChannelRegistry
└── register(adapter: IChannelAdapter): void └── register(adapter: OfficialChannelAdapter): void
└── getAdapter(name: string): IChannelAdapter | null └── getAdapter(name: string): OfficialChannelAdapter | null
└── listAdapters(): IChannelAdapter[] └── listAdapters(): OfficialChannelAdapter[]
└── healthAll(): Promise<Record<string, AdapterHealth>> └── healthAll(): Promise<Record<string, AdapterHealth>>
``` ```
--- ---
## M7-002: ChannelMessage Protocol ## M7-002: ChannelMessageDto Protocol
### Canonical Message Format ### Canonical Message Format
```typescript ```typescript
interface ChannelMessage { interface ChannelMessageDto {
/** /**
* Globally unique message ID. * Globally unique message ID.
* Format: UUID v4. Generated by the adapter when receiving, or by Mosaic * Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
@@ -110,6 +97,7 @@ interface ChannelMessage {
* The adapter populates this from the inbound message. * The adapter populates this from the inbound message.
* For outbound messages, the caller supplies the target channel. * For outbound messages, the caller supplies the target channel.
*/ */
channelName: string;
channelId: string; channelId: string;
/** /**
@@ -119,7 +107,7 @@ interface ChannelMessage {
senderId: string; senderId: string;
/** Sender classification. */ /** Sender classification. */
senderType: 'user' | 'agent' | 'system'; senderKind: 'user' | 'agent' | 'system';
/** /**
* Textual content of the message. * Textual content of the message.
@@ -136,7 +124,7 @@ interface ChannelMessage {
* - "image" — binary image; content is empty, see attachments * - "image" — binary image; content is empty, see attachments
* - "file" — binary file; content is empty, see attachments * - "file" — binary file; content is empty, see attachments
*/ */
contentType: 'text' | 'markdown' | 'code' | 'image' | 'file'; contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file';
/** /**
* Arbitrary key-value metadata for channel-specific extension fields. * Arbitrary key-value metadata for channel-specific extension fields.
@@ -144,7 +132,7 @@ interface ChannelMessage {
* Adapters should store channel-native IDs here so round-trip correlation * Adapters should store channel-native IDs here so round-trip correlation
* is possible without altering the canonical fields. * is possible without altering the canonical fields.
*/ */
metadata: Record<string, unknown>; metadata: Readonly<Record<string, ChannelMetadataValue>>;
/** /**
* Optional thread or reply-chain identifier. * Optional thread or reply-chain identifier.
@@ -163,18 +151,21 @@ interface ChannelMessage {
* Binary or URI-referenced attachments. * Binary or URI-referenced attachments.
* Each attachment carries its MIME type and a URL or base64 payload. * Each attachment carries its MIME type and a URL or base64 payload.
*/ */
attachments?: ChannelAttachment[]; attachments?: readonly ChannelAttachmentDto[];
/** Wall-clock timestamp when the message was sent/received. */ /** ISO-8601 wall-clock timestamp when the message was sent/received. */
timestamp: Date; timestamp: string;
} }
interface ChannelAttachment { interface ChannelAttachmentDto {
/** Filename or identifier. */ /** Channel-native attachment identifier. */
id: string;
/** Filename or display name. */
name: string; name: string;
/** MIME type (e.g. "image/png", "application/pdf"). */ /** MIME type when supplied by the channel. */
mimeType: string; mimeType: string | null;
/** /**
* URL pointing to the attachment, OR a `data:` URI with base64 payload. * URL pointing to the attachment, OR a `data:` URI with base64 payload.
@@ -192,23 +183,23 @@ interface ChannelAttachment {
## Channel Translation Reference ## Channel Translation Reference
The following sections document how each supported channel maps its native message format to and from `ChannelMessage`. The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`.
### Matrix ### Matrix
| ChannelMessage field | Matrix equivalent | | ChannelMessageDto field | Matrix equivalent |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) | | `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
| `channelId` | Matrix room ID (`!roomid:homeserver`) | | `channelId` | Matrix room ID (`!roomid:homeserver`) |
| `senderId` | Matrix user ID (`@user:homeserver`) | | `senderId` | Matrix user ID (`@user:homeserver`) |
| `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound | | `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
| `content` | `event.content.body` | | `content` | `event.content.body` |
| `contentType` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` | | `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` | | `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']` | | `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 | | `attachments` | Populated from `url` in `m.image` / `m.file` events |
| `timestamp` | `new Date(event.origin_server_ts)` | | `timestamp` | `new Date(event.origin_server_ts)` |
| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` | | `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. **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.
@@ -216,21 +207,34 @@ The following sections document how each supported channel maps its native messa
### Discord ### Discord
| ChannelMessage field | Discord equivalent | | ChannelMessageDto field | Discord equivalent |
| -------------------- | ----------------------------------------------------------------------- | | ----------------------- | ----------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake | | `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
| `channelId` | Discord channel ID (snowflake string) | | `channelId` | Discord channel ID (snowflake string) |
| `senderId` | Discord user ID (snowflake) | | `senderId` | Discord user ID (snowflake) |
| `senderType` | `"user"` for human members; `"agent"` for bot messages | | `senderKind` | `"user"` for human members; `"agent"` for bot messages |
| `content` | `message.content` | | `content` | `message.content` |
| `contentType` | `"markdown"` (Discord uses a markdown-like syntax natively) | | `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) |
| `threadId` | `message.thread.id` when the message is inside a thread channel | | `threadId` | `message.thread.id` when the message is inside a thread channel |
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` | | `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
| `attachments` | `message.attachments` mapped to `ChannelAttachment` | | `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` |
| `timestamp` | `new Date(message.timestamp)` | | `timestamp` | `new Date(message.timestamp)` |
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` | | `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. **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 ### Discord service ingress security
@@ -240,21 +244,21 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket
### Telegram ### Telegram
| ChannelMessage field | Telegram equivalent | | ChannelMessageDto field | Telegram equivalent |
| -------------------- | ------------------------------------------------------------------------------------------------------------- | | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) | | `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
| `channelId` | Telegram `chat_id` (integer as string) | | `channelId` | Telegram `chat_id` (integer as string) |
| `senderId` | Telegram `from.id` (integer as string) | | `senderId` | Telegram `from.id` (integer as string) |
| `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages | | `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages |
| `content` | `message.text` or `message.caption` | | `content` | `message.text` or `message.caption` |
| `contentType` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` | | `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
| `threadId` | `message.message_thread_id` (for supergroup topics) | | `threadId` | `message.message_thread_id` (for supergroup topics) |
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` | | `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` | | `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` |
| `timestamp` | `new Date(message.date * 1000)` | | `timestamp` | `new Date(message.date * 1000)` |
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` | | `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`. **Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
--- ---
@@ -262,19 +266,19 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket
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. 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 | | ChannelMessageDto field | TUI equivalent |
| -------------------- | ------------------------------------------------------------------ | | ----------------------- | ------------------------------------------------------------------ |
| `id` | Generated UUID (TUI has no native message IDs) | | `id` | Generated UUID (TUI has no native message IDs) |
| `channelId` | `"tui:<conversationId>"` — the active conversation ID | | `channelId` | `"tui:<conversationId>"` — the active conversation ID |
| `senderId` | Authenticated Mosaic `userId` | | `senderId` | Authenticated Mosaic `userId` |
| `senderType` | `"user"` for human input; `"agent"` for agent replies | | `senderKind` | `"user"` for human input; `"agent"` for agent replies |
| `content` | Raw text from stdin / agent output | | `content` | Raw text from stdin / agent output |
| `contentType` | `"text"` for input; `"markdown"` for agent responses | | `contentKind` | `"text"` for input; `"markdown"` for agent responses |
| `threadId` | Not used (TUI sessions are linear) | | `threadId` | Not used (TUI sessions are linear) |
| `replyToId` | Not used | | `replyToId` | Not used |
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs | | `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
| `timestamp` | `new Date()` at the moment of send | | `timestamp` | `new Date()` at the moment of send |
| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` | | `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`. **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`.
@@ -284,19 +288,19 @@ The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the chan
The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`). 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 | | ChannelMessageDto field | WebUI equivalent |
| -------------------- | ------------------------------------------------------------ | | ----------------------- | ------------------------------------------------------------ |
| `id` | Generated UUID; echoed back in the WebSocket event | | `id` | Generated UUID; echoed back in the WebSocket event |
| `channelId` | `"webui:<conversationId>"` | | `channelId` | `"webui:<conversationId>"` |
| `senderId` | Authenticated Mosaic `userId` | | `senderId` | Authenticated Mosaic `userId` |
| `senderType` | `"user"` for browser input; `"agent"` for agent responses | | `senderKind` | `"user"` for browser input; `"agent"` for agent responses |
| `content` | Message text from the input field | | `content` | Message text from the input field |
| `contentType` | `"text"` or `"markdown"` | | `contentKind` | `"text"` or `"markdown"` |
| `threadId` | Not used (conversation model handles threading) | | `threadId` | Not used (conversation model handles threading) |
| `replyToId` | Message ID the user replied to (UI reply affordance) | | `replyToId` | Message ID the user replied to (UI reply affordance) |
| `attachments` | Files uploaded via the file picker; stored to object storage | | `attachments` | Files uploaded via the file picker; stored to object storage |
| `timestamp` | `new Date()` at send, or server timestamp from event | | `timestamp` | `new Date()` at send, or server timestamp from event |
| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` | | `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. **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.
@@ -304,7 +308,7 @@ The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel prot
## Identity Mapping ## 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. 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). 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).
@@ -323,8 +327,8 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
## Error Handling Conventions ## Error Handling Conventions
- `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s). - `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.
- `sendMessage()` must throw `ChannelSendError` on terminal failures (auth revoked, channel not found). Transient failures (rate limit, network blip) should be retried internally with exponential backoff before throwing. - `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. - `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability. - Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.
@@ -332,7 +336,7 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
## Versioning ## 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. 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** Current version: **1.0.0**
@@ -473,7 +477,7 @@ A single Mosaic conversation can be accessed simultaneously from multiple surfac
### Real-Time Sync Flow ### Real-Time Sync Flow
1. A message arrives on any surface (TUI keystroke, browser send, Matrix event). 1. A message arrives on any surface (TUI keystroke, browser send, Matrix event).
2. The surface's adapter normalizes the message to `ChannelMessage` and delivers it to `ConversationService`. 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`. 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: 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. - TUI adapter: writes rendered output to the connected terminal session.
@@ -561,7 +565,7 @@ Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessi
- Their `channel_identities` row exists (link not revoked). - Their `channel_identities` row exists (link not revoked).
- They remain members of the relevant Matrix rooms. - 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). 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).
--- ---
@@ -733,7 +737,7 @@ room_retention_policies
created_at TIMESTAMP 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. 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: Default retention values:

View File

@@ -0,0 +1,86 @@
# Fleet Configuration Management — Documentation IA Acceptance Checklist
**Issue:** #758 · **Scope:** M0 documentation gate for the local fleet declarative-configuration program.
This checklist is an acceptance contract for documentation and examples. It does not authorize
schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its
named artifact exists, is linked from the fleet documentation entry point, and its evidence is
recorded in the M0 task/PR.
## M0 baseline acceptance
- [ ] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd,
tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of
unsupported or quarantined legacy input.
- [ ] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but
does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader`
capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess
and Ultron remain configurable.
- [ ] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
observed state, including stopped-state preservation through migration, apply, and reboot.
- [ ] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary
command overrides in M1M5, and requires key-name/hash-only quarantine diagnostics.
- [ ] `docs/PRD.md` identifies the M1M5 local-tmux scope and excludes remote reconciliation,
connector mutation, secret references, arbitrary commands/channels, gateway convergence, and
UI configuration storage.
- [ ] `docs/TASKS.md` contains the complete M0M5 one-card/one-PR dependency DAG for #758 with
agent tier, branch, dependency, estimate, and evidence expectations.
- [ ] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped
fleet example, profile, and service preset before M1 implementation starts.
## Required documentation IA for M1M5
| Path | Minimum content | Delivery gate |
| ------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
| `docs/fleet/README.md` | Fleet configuration entry point, desired-vs-observed decision tree, link map | M5 |
| `docs/fleet/concepts/desired-vs-observed-state.md` | SSOT/projection model, drift, generation and ownership | M5 |
| `docs/fleet/concepts/identity-class-runtime.md` | Stable name, display alias, class, runtime/provider/model separation | M5 |
| `docs/fleet/concepts/role-authority-and-leases.md` | Required roles, validator/merge-gate separation, lease limits | M5 |
| `docs/fleet/concepts/generated-env-launch-chain.md` | Generated/local files, precedence, quarantine and non-shell parsing | M5 |
| `docs/fleet/reference/roster-v2.schema.json` | Executable v2 structural contract | M1 |
| `docs/fleet/reference/roster-v2-fields.md` | Every field, default, constraint, compatibility behavior and examples | M1 |
| `docs/fleet/reference/cli.md` | `config`, `agent`, lifecycle, plan/apply, JSON and exit-code contracts | M2M3 |
| `docs/fleet/reference/role-classes.md` | Canonical classes, aliases, authority matrix and instance-name rule | M1 |
| `docs/fleet/reference/lifecycle-transitions.md` | Create/start/stop/restart/apply/reboot/rollback transition table | M3 |
| `docs/fleet/reference/status-and-drift.md` | Desired/observed/managed state, orphans, revision mismatch, doctor output | M3 |
| `docs/fleet/how-to/create-update-delete-agent.md` | Safe CRUD, expected generation, dry-run and rollback | M2 |
| `docs/fleet/how-to/start-stop-restart.md` | Persisted versus one-shot lifecycle actions | M3 |
| `docs/fleet/how-to/configure-tess-interaction.md` | Configurable interaction instance; no hardcoded identity | M5 |
| `docs/fleet/how-to/configure-ultron-validator.md` | Configurable validator instance; no merge authority | M5 |
| `docs/fleet/how-to/customize-roles.md` | Existing baseline + `roles.local` resolution and validation | M1 |
| `docs/fleet/operations/reconcile-and-recover.md` | Plan/apply failure recovery, generation lock and canary rollout | M3 |
| `docs/fleet/operations/env-quarantine.md` | Legacy-key inventory, private quarantine and redaction behavior | M2 |
| `docs/fleet/operations/systemd-tmux-troubleshooting.md` | Socket ambiguity, ownership proof, systemd/tmux drift | M3 |
| `docs/fleet/operations/backup-restore.md` | Roster/projection backup and rollback boundaries | M4 |
| `docs/fleet/operations/upgrade-assets.md` | Source-vs-installed asset revision detection and safe refresh | M5 |
| `docs/fleet/migration/v1-to-v2.md` | Normative field map, observed-state preservation and rollback | M4 |
| `docs/fleet/migration/example-profile-disposition.md` | Final disposition of every shipped example/profile | M1M4 |
| `docs/fleet/migration/legacy-class-aliases.md` | Alias, unresolved-class, and retirement rules | M1 |
## PRD acceptance-criteria mapping
| PRD acceptance criterion | Owning card(s) | Required evidence |
| ------------------------------------------------------------ | ---------------------------------- | --------------------------------------------------------------------------------------- |
| `AC-FCM-01` schema, semantic validation, canonical rendering | FCM-M1-001, FCM-M1-002 | YAML/JSON positive/negative and schema/parser/resolver parity tests |
| `AC-FCM-02` deterministic plan and no-mutation check | FCM-M3-001 | Stable JSON/exit-code and desired-versus-observed fixture tests |
| `AC-FCM-03` safe generation-guarded CRUD | FCM-M2-002 | Create/update/delete idempotency, expected-generation, dry-run, and recovery tests |
| `AC-FCM-04` generated/local boundary and quarantine | FCM-M2-001 | Launch-chain, shadow, injection, redaction, and forbidden-key tests |
| `AC-FCM-05` lifecycle/reconcile/socket/drift safety | FCM-M3-001, FCM-M3-002 | Isolated systemd/tmux, stopped-state, orphan, socket, and rollback evidence |
| `AC-FCM-06` v1 migration and example/profile disposition | FCM-M4-001, FCM-M4-002, FCM-M1-003 | Preview/canary/rollback fixture plus executable disposition inventory |
| `AC-FCM-07` authority and lease boundaries | FCM-M1-002 | Role/authority/lease denial tests and resolved role contracts |
| `AC-FCM-08` documentation and final release gate | FCM-M5-001, FCM-M5-002 | Checklist closure, link/example validation, reviews, certificate, and terminal-green CI |
## Cross-cutting evidence gates
- [ ] Every retained or migrated YAML/JSON example, profile, and service preset validates through the
same executable schema and shared baseline-plus-`roles.local` resolver used by the CLI.
- [ ] Every retired example/profile/service preset has a replacement link and deprecation note; no
unresolved legacy class or tool-policy alias remains silently shipped.
- [ ] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded
Tess/Ultron identity.
- [ ] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed
`mosaic agent` catalog.
- [ ] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of
legacy sensitive keys are never printed.
- [ ] M5 release review verifies links, schema/example validation, and that all checklist rows have
owner/evidence or an explicit approved deferral.

View File

@@ -0,0 +1,56 @@
# Fleet Configuration Management — Legacy Example, Profile, and Service Disposition Inventory
**Issue:** #758 · **Baseline:** `origin/main` `49e8a541` · **Status:** M0 inventory; no source
examples or profiles are changed by this document.
The v2 compiler may not silently accept an unresolved class. Before M1 exits, every shipped file
below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or
retired with a replacement/deprecation note. Class resolution must use the existing
profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a
parallel resolver.
## Examples
| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence |
| ---------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `framework/fleet/examples/coding.yaml` | `orchestrator`, `enhancer`, `implementer`, `reviewer` | Migrate: `implementer → code`, `reviewer → review`; retain orchestration/enhancer intent | v2 fixture validates; role aliases and authority matrix tested |
| `framework/fleet/examples/general.yaml` | `orchestrator`, `enhancer`, `worker` | Migrate only after operator chooses a concrete canonical role for `worker`; no implicit conversion | Explicit replacement class, or versioned v1 fixture/retirement note |
| `framework/fleet/examples/hybrid.yaml` | `orchestrator`, `enhancer`, `implementer`, `researcher`, `reviewer` | Migrate aliases; resolve `researcher` through existing role resolver or retain/version | Shared resolver validation; no ad-hoc class scanner |
| `framework/fleet/examples/local-canary.yaml` | `orchestrator`, `implementer`, `reviewer` | Migrate aliases; preserve its local-tmux canary purpose | v2 fixture validates and preserves safe stopped/running behavior |
| `framework/fleet/examples/minimal.yaml` | `canary` | Retire or version as v1 unless an existing canonical role contract is selected deliberately | Replacement link/deprecation note or CI-valid v1 fixture |
| `framework/fleet/examples/operator-interaction.yaml` | `operator-interaction` | Migrate alias to `interaction`; preserve instance/display name as configuration, not schema identity | v2 interaction fixture validates; no Tess literal is required |
| `framework/fleet/examples/research.yaml` | `orchestrator`, `enhancer`, `researcher`, `analyst` | Resolve `researcher`/`analyst` through baseline + `roles.local`, or version/retire | Resolver evidence and explicit disposition for each unresolved class |
## Profiles
| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `framework/fleet/profiles/business.yaml` | `ceo`, `coo`, `cfo`, `product-manager`, `marketing-lead`, `sales-lead`, `operations-manager`, `customer-success-manager`, `code`, `review` | Retain only if every class resolves through the existing role library/`roles.local`; otherwise version/retire rather than weakening validation | Shared resolver CI result for every class; documented role source or replacement |
| `framework/fleet/profiles/marketing.yaml` | `marketing-lead`, `content-strategist`, `copywriter`, `seo-specialist`, `social-media-manager`, `brand-strategist`, `growth-marketer`, `ux-designer` | Same resolver-or-version/retire rule | Per-class resolver CI result and replacement/deprecation record if unresolved |
| `framework/fleet/profiles/personal-assistant.yaml` | `personal-assistant`, `executive-assistant`, `scheduler`, `inbox-manager`, `researcher` | Same resolver-or-version/retire rule | Per-class resolver CI result; do not infer `interaction` equivalence |
| `framework/fleet/profiles/research.yaml` | `lead-researcher`, `researcher`, `data-analyst`, `data-scientist`, `market-analyst`, `documentation`, `review` | Same resolver-or-version/retire rule | Per-class resolver CI result and explicit compatibility posture |
| `framework/fleet/profiles/software-delivery.yaml` | `orchestrator`, `board`, `planner`, `decomposition`, `code`, `review`, `security-review`, `site-tester`, `documentation`, `merge-gate`, `rebase`, `operator`, `session-review`, `enhancer` | Retain as the governance reference; add `validator`, `team-leader`, and `interaction` only through approved role/profile work, not silent substitution | CI validates all current classes; separate fixture proves required M1 authority seats |
## Service presets
| Shipped file | Current policy evidence | M0 disposition decision | Required M1/M4 evidence |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `framework/fleet/services/operator-interaction.yaml` | Generic policy only: `runtime: pi`, `model: openai/gpt-5.6-sol`, `reasoning: high`, `tool_policy: operator-interaction`; provisioning supplies the agent name as data | Retain as a generic service policy, not a Tess identity. Migrate `tool_policy: operator-interaction` only through the approved interaction tool-policy alias/semantic resolver; do not infer a class or machine name from this file. | Service-policy fixture validates runtime/model/reasoning and alias behavior; generic provisioning proves a configured interaction instance is supplied without a hardcoded Tess name. |
## Required disposition controls
1. **No silent aliasing:** only `implementer → code`, `reviewer → review`, and
`operator-interaction → interaction` are approved deterministic aliases in this M0 baseline.
`worker`, `analyst`, `canary`, and domain-specific classes require resolver evidence or an
explicit version/retirement decision.
2. **No identity hardcoding:** Tess and Ultron are optional instance/display names. An example/profile
may demonstrate the capability but must not make a product name a required class or machine ID.
3. **No lifecycle inference from an example:** examples describe desired configuration only; migration
of an installed v1 roster separately preserves observed stopped/running state.
4. **No secret or command migration:** examples/profiles must not introduce credential values or
`MOSAIC_AGENT_COMMAND`; those legacy keys are M2 quarantine inputs, never v2 authoring fields.
5. **Service presets are included:** service policies are inventoried alongside examples/profiles.
They may express launch/tool policy, but do not create a class, a canonical agent identity, or a
second validation path.
6. **Evidence is executable:** M1/M4 CI must enumerate these exact files, validate retained/migrated
inputs through the shared resolver, and fail if a file lacks its documented disposition.

View File

@@ -0,0 +1,95 @@
# Fleet Roster v2 Structural Contract
**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing,
strict structural validation, normalized in-memory representation, and deterministic rendering only.
It does not authorize role resolution, lifecycle reconciliation, mutation, migration, remote
placement, connector configuration, secret references, arbitrary commands, channels, gateway
mapping, or any live-fleet change.
The executable schema is [`roster-v2.schema.json`](./roster-v2.schema.json). The compiler exports
the same schema and its test parses this file and compares it structurally with the executable contract.
## Format and canonical shape
The compiler accepts YAML or JSON. It reads only snake_case source fields and renders canonical,
snake_case YAML. Rendering sorts runtime keys and agents by stable name. Agent names, class names,
and tool-policy names are structural identifiers; whether a class or policy resolves is a later
shared-resolver concern.
```yaml
version: 2
generation: 1
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~/src
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: ~/src
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
```
## Root fields
| Field | Required | Constraint | Meaning |
| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | yes | integer constant `2` | Identifies this contract. Version `1` is explicitly rejected by this compiler and remains on the existing v1 path until M4 migration. |
| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. |
| `transport` | yes | constant `tmux` | M1M5 support local tmux only. |
| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. |
| `defaults` | yes | strict object | Default work directory and one supported local runtime. |
| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. |
| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
## Nested fields
| Path | Required | Constraint |
| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity |
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
| `defaults.working_directory` | yes | non-empty string |
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
| `agents[].alias` | yes | non-empty display string |
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
| `agents[].reasoning` | yes | `low`, `medium`, or `high` |
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
## Fail-closed boundary
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket
per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields,
and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently
ignore v1 camelCase input, version `1`, or a source that does not parse to an object.
The v2 compiler is intentionally isolated from the existing v1 loader. Existing v1 rosters and
current examples/profiles continue on their current path; FCM-M4 owns explicit inventory, preview,
migration, and rollback. FCM-M2 owns generated-file/local-override quarantine, and FCM-M3 owns
runtime lifecycle and reconciliation.

View File

@@ -0,0 +1,156 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json",
"title": "Mosaic local tmux fleet roster v2",
"type": "object",
"additionalProperties": false,
"required": ["version", "generation", "transport", "tmux", "defaults", "runtimes", "agents"],
"properties": {
"version": {
"const": 2
},
"generation": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"transport": {
"const": "tmux"
},
"tmux": {
"type": "object",
"additionalProperties": false,
"required": ["socket_name", "holder_session"],
"properties": {
"socket_name": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+$"
},
"holder_session": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+$"
}
}
},
"defaults": {
"type": "object",
"additionalProperties": false,
"required": ["working_directory", "runtime"],
"properties": {
"working_directory": {
"type": "string",
"minLength": 1
},
"runtime": {
"enum": ["claude", "codex", "opencode", "pi"]
}
}
},
"runtimes": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"enum": ["claude", "codex", "opencode", "pi"]
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["reset_command"],
"properties": {
"reset_command": {
"type": "string",
"minLength": 1
}
}
}
},
"agents": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"alias",
"class",
"runtime",
"provider",
"model",
"reasoning",
"tool_policy",
"working_directory",
"persistent_persona",
"reset_between_tasks",
"lifecycle",
"launch"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$"
},
"alias": {
"type": "string",
"minLength": 1
},
"class": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"runtime": {
"enum": ["claude", "codex", "opencode", "pi"]
},
"provider": {
"type": "string",
"minLength": 1
},
"model": {
"type": "string",
"minLength": 1
},
"reasoning": {
"enum": ["low", "medium", "high"]
},
"tool_policy": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"working_directory": {
"type": "string",
"minLength": 1
},
"persistent_persona": {
"type": "boolean"
},
"reset_between_tasks": {
"type": "boolean"
},
"lifecycle": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "desired_state"],
"properties": {
"enabled": {
"type": "boolean"
},
"desired_state": {
"enum": ["running", "stopped"]
}
}
},
"launch": {
"type": "object",
"additionalProperties": false,
"required": ["yolo"],
"properties": {
"yolo": {
"type": "boolean"
}
}
}
}
}
}
}
}

View File

@@ -293,24 +293,64 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
### Plugins ### Plugins
| Variable | Description | | Variable | Description |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | | `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only | | `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only |
| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata | | `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata |
| `DISCORD_GUILD_ID` | Discord guild/server ID | | `DISCORD_GUILD_ID` | Discord guild/server ID |
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | | `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny | | `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny |
| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny | | `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny |
| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny | | `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | | `DISCORD_INTERACTION_BINDINGS` | Required JSON bindings from guild/channel to logical agent and paired Discord users with `viewer`, `operator`, or `admin` roles |
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | | `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Optional positive integer; authorized turns per guild/channel/user each minute (default: `30`) |
| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Optional positive integer; mention-thread routes per guild/channel/user each minute (default: `5`) |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
### Discord ingress security ### Discord ingress security
When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged. When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged.
Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state. Inbound Discord messages must originate from an allowed guild and configured parent channel, come from an allowed and paired user whose role permits sending, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. Attachment references are limited in count, metadata size, field length, and declared size; only query-free HTTPS URLs without credentials or fragments are accepted, so bearer or presigned URLs never reach persistence or an agent prompt. The gateway validates the service identity, envelope signature, allowlists, pairing, and role again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state.
Configured channels are dedicated agent interaction surfaces. An authorized untagged message routes to the bound logical agent and the response returns in that channel. Mentioning the bot on a normal channel message creates a public Discord thread, or reuses the thread already attached to that same message; the response and later thread messages stay in that thread without repeated mentions. A normal channel's category is not an authorization parent—only a Discord thread inherits authorization from its configured parent channel. Runtime control commands such as `/approve` and `/stop <approval>` remain on the current channel/thread because they target that durable session rather than opening a new topic.
Authorization and per-user/channel rate limits are evaluated before thread creation, so an unlisted guild/channel/user, unpaired user, `viewer`, or rate-limited sender cannot create bot threads or dispatch gateway work. The bot needs Discord permissions to view/send in configured channels and create/send in public threads. If thread creation fails, the turn is not dispatched because the requested response destination cannot be honored.
Conversation handles use the configured logical agent plus Discord channel/thread identity. They do not contain a Claude, Codex, Pi, OpenCode, model, process, or runtime-provider identifier; changing the runtime behind the logical session therefore does not require reconnecting the Discord bot.
#### Interaction binding format
`DISCORD_INTERACTION_BINDINGS` must be a non-empty JSON array. Each item requires `instanceId`, trusted `agentConfigId`, `guildId`, `channelId`, and a non-empty `pairedUsers` object. `instanceId` is the configured logical-agent name; its trusted database `agentConfigId` must resolve to an agent configuration with exactly that name, preserving provider/model/prompt/tool selection per binding. The guild/channel must also appear in their corresponding allowlists. IDs below are placeholders:
```json
[
{
"instanceId": "interaction-agent",
"agentConfigId": "agent-config-id",
"guildId": "guild-id",
"channelId": "channel-id",
"pairedUsers": {
"discord-user-id": {
"role": "operator",
"mosaicUserId": "mosaic-user-id"
}
}
}
]
```
| Pairing role | Send message | Create/continue thread | Approve | Stop |
| ------------ | ------------ | ---------------------- | ------- | ---- |
| `viewer` | No | No | No | No |
| `operator` | Yes | Yes | No | No |
| `admin` | Yes | Yes | Yes | Yes |
A legacy role-only value such as `"discord-user-id": "operator"` remains valid for non-privileged ingress. Approval and stop require the object form with a provisioned `mosaicUserId`; gateway policy checks that Mosaic identity and consumes one exact-action approval once. Do not make the Discord service principal an approving administrator.
After changing bindings or allowlists, restart the gateway/plugin through the normal service manager and verify both Discord and gateway connectivity. The adapter reports `connected` only when both links are ready, `degraded` when one is ready, and `disconnected` when neither is ready. Test one authorized untagged channel turn, one mention-created thread, one thread follow-up, and one unauthorized user denial without using production credential values in logs or evidence.
### Session retention and garbage collection ### Session retention and garbage collection

View File

@@ -0,0 +1,415 @@
# KBN-010 — Threat, Authorization, and Constraint-Impact Gate
- **Issue:** [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753)
- **Gate status:** **PASS / GO**
- **Reviewed baseline:** `origin/main` at `49e8a54` (2026-07-14)
- **Frozen target:** `SHARED-CONTRACT.md` v1.0.0-rc.4 and `contracts/*.v1.ts`
- **Disposition input:** contract commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`
- **Scope:** documentation and future-test planning only; no runtime, schema, migration, API, configuration, dependency, CI, or deployment change
## 1. Decision
KBN-010 is **PASS / GO** against frozen contract rc.4. The original rc.3 finding remains historical detection evidence:
- **KBN010-SI-001 — rc.3 invalid mission composite-FK candidate key.** At rc.3, `missionsV1` declared a primary key on `id` and a unique key on `(workspace_id, project_id, id)`, but not a candidate key on `(workspace_id, id)`. Both `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` referenced exactly `(missions.workspace_id, missions.id)`. PostgreSQL requires the referenced column list of a foreign key to match a non-partial unique/primary candidate key; uniqueness of `id` alone did not satisfy that two-column reference. The rc.3 DDL was therefore invalid, and KBN-010 correctly blocked it.
Contract rc.4 resolves SI-001 by adding the non-partial `missions_workspace_id_uidx` candidate key on `(workspace_id, id)` while retaining the global `id` primary key and the project-congruent `(workspace_id, project_id, id)` key. Both polymorphic child FKs retain their exact workspace-safe ordered columns and `ON DELETE RESTRICT`; no target, tenancy, project-congruence, exactly-one-target, N-1, rollback, no-cascade, identity, approval, or fencing authority is weakened.
Independent Homelab non-author schema/security review returned **APPROVE** for the exact rc.4 commit/tree/content and found no collision with #757 connector fencing. SI-001 has no unresolved contract/schema-design impact.
This GO completes the KBN-010 analysis/review prerequisite only. It does **not** claim that runtime schema or migration DDL exists. KBN-100 remains held and may be released only after this PR squash-merges, the merged change reaches terminal-green CI on `main`, and issue #753 closes.
### 1.1 Independent rc.4 evidence identity
- **Commit:** `3f6a3387b419eb99453ee10dd25ba888faaab0b5`
- **Tree:** `7ebab8fa530a7180036928cea9527f808548aa14`
- **Stable full-index SHA-256:** `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`
- **Stable patch-id:** `058cf98026fcd1043703c866aee047c8bb144740`
- **Verdict:** Homelab independent non-author schema/security review **APPROVE**.
- **Reviewed conclusions:** the candidate key repairs both dependent FKs; tenant safety, polymorphic exactly-one-target semantics, RESTRICT/no-cascade behavior, and N-1/rollback semantics remain valid; #757 uses separate tables/indexes/FKs/identity/fence authority and has no collision.
A command-rendered patch SHA may differ when Git rendering options, headers, or command form differ. That rendering digest is non-authoritative. Canonical review identity is the Git commit object plus tree and exact file content; the stable full-index digest and stable patch-id above are corroborating identities.
## 2. Method and trust boundaries
### 2.1 Inputs inspected
- Canonical requirements: `docs/requirements/native-kanban-sot.md`.
- Workstream manifest and read-only task plan.
- Frozen health, schema, Mechanical Coordinator, and recovery contracts in full.
- Actual current-main schema, Better Auth guard/scope helpers, project/task/mission/team controllers and repositories, fleet backlog, and `TASKS.md` parser/writer.
- Issue #753 through the Mosaic provider wrapper.
### 2.2 Current-main exposure that the target must replace, not inherit
| Current-main fact | Constraint on future implementation |
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Teams are global; projects, missions, tasks, agents, and fleet backlog have no `workspace_id`. | KBN-100 must add the workspace boundary and KBN-110 must query by server-derived workspace in every repository operation. |
| `AuthGuard` authenticates a Better Auth user, while `scopeFromUser` falls back through optional tenant/team/org claims and finally user ID. | Kanban tenancy must derive from an authenticated **active workspace membership**, not this compatibility fallback or caller data. |
| Team list/get/member endpoints return global team data to any authenticated user. | New Kanban endpoints must use a uniform no-oracle denial and must not reuse global team lookup as authorization. |
| Project/task repositories load and mutate by bare IDs; controller checks are separate and sometimes distinguish not-found from forbidden. | Workspace predicates and authorization must be inside the authoritative transaction/repository command path. |
| Tasks can have nullable project/mission links, free-text assignee, JSON tags, no aggregate version, and no fence. | Expand/backfill/quarantine must precede NOT NULL/composite constraints; new commands cannot trust legacy fields. |
| `mission_tasks.status` is a second status writer. | Pre-expand must prohibit it as a write source and later retire it only after N-1 evidence. |
| Fleet `backlog` has global JSON dependencies and TTL claims without workspace, assignment, approval, session, or fencing. | It must be frozen and imported as non-dispatching shadow data; it cannot be adapted into the canonical lease path. |
| `packages/coord/src/tasks-file.ts` parses and mutates `TASKS.md`. | KBN-120 must replace production use with generated, read-only projection code and prove there is no import/mutation path. |
| No Kanban transaction-local health proof, semantic audit/event chain, change proposals, canonical outbox, approval binding, or fenced lease model exists. | These are new frozen invariants, not behaviors that may be inferred from current endpoints. |
## 3. Authorization matrix
The exact route/DTO freeze belongs to KBN-105. This matrix fixes the minimum authorization behavior that freeze and later implementation must preserve.
| Principal/state | Permitted authority | Required authoritative checks | Explicit denials |
| -------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Unauthenticated caller | Public health observation only, if deployment exposes it | Health DTO validation; no proof field accepted | All canonical reads/mutations; health observation never authorizes a write |
| Active workspace `owner`/`admin` user | Policy-allowed workspace administration and domain commands | Better Auth session; active membership; server-derived workspace; command-family role; expected version/idempotency | Foreign workspace, suspended workspace, revoked membership, caller workspace override |
| Active workspace `member` user | Policy-allowed project/task/proposal commands | Active membership plus project/team capability and target checks in the same transaction | Admin, approval, purge, service-only Coordinator, and unrelated project commands |
| Active workspace `auditor` user | Workspace-scoped reads and audit/evidence inspection | Active membership and read capability | Every mutation, approval, lease, token issuance, purge |
| Active workspace `service` identity | Only explicitly issued command families | Credential maps to workspace+agent+session; agent enabled; session live; role/capability allowlist; token expiry/audience; DB recheck per command | Raw DB credentials, user/admin fallback, cross-workspace scope, command families absent from token and registry |
| Enabled agent with live session | Agent commands matching its declared and policy-approved specialist role/capabilities | Exact workspace+agent+session binding, heartbeat/state, assignment target, lease, current decimal-string fence | Ended/offline/degraded session where policy disallows; disabled agent; another assignment/session/fence |
| Mechanical Coordinator engine | Pure eligibility/order/expiry decisions from immutable snapshots | Complete workspace-local snapshot and policy revision | Authentication, ID loading, SQL, proof minting, scope invention, approval, certification, merge |
| Coordinator persistence service | Service-only assignment/lease/checkpoint/recovery commands | Fresh transaction-local proof; locks; current assignment/approval/task/session/policy/fence | Public/user proof-by-value, stale approval/policy, direct completion/certification/merge |
| Reviewer/SecReview/Certifier | Attributable evidence decisions allowed by gate policy | Active authority, author differs from reviewer, mandatory SecReview classification, immutable artifacts | Self-review; missing evidence; Certifier merge/issue-close/release |
| Break-glass retention operator | Narrow, time-bounded purge procedure only | Separate break-glass authority, reason, scope, approvals, immutable pre-purge evidence, semantic audit, post-action reconciliation | Normal application role DELETE/UPDATE, bulk unscoped purge, unaudited hard delete |
| Revoked/expired/disabled identity or ended session | None beyond policy-permitted public observation | Revocation/lifecycle checked from PostgreSQL on every command | Cached token/Valkey state cannot preserve authority |
**No-oracle rule:** authentication may return 401, but once authenticated, a foreign-workspace, nonexistent, inaccessible, or wrong-project identifier must follow the one KBN-105-frozen 404/403 policy with the same response shape and no foreign metadata, timing-derived detail, or WebSocket/MCP discrepancy.
## 4. Threat matrix
Every disposition is against the frozen target, not a claim about current-main behavior.
| ID | Attacker or failure | Asset | Precondition and abuse path | Frozen preventive/detective control | Required schema/API/negative-test evidence | Future owner | Residual risk | Disposition |
| --- | --------------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| T01 | Authenticated user supplies a foreign workspace/resource ID | Tenant confidentiality and integrity | Caller knows or guesses project/task/mission/team IDs and probes REST, MCP, WebSocket, repository, or Coordinator paths | `workspace_id` on every canonical row; composite relations; server-derived tenant; uniform no-oracle denial | Composite FK/unique DDL; every repository predicate includes workspace; N100-01/02, N110-01..05, N130-01 | KBN-100, 105, 110, 130 | Timing/volume side channels require operational review | Controlled after evidence |
| T02 | Revoked or inactive member retains an old session | Ownership and mutation authority | Authentication remains valid after workspace membership revocation | Active membership rechecked in the authoritative transaction for owners, principals, proposers, and decision actors | Active/inactive membership fixtures; N100-03, N110-06/07; no cached membership authority | KBN-100, 110 | Better Auth session may remain valid for unrelated features | Controlled after evidence |
| T03 | User joins/forges a team relation outside its workspace | Team-owned projects and tasks | Global-current-main team behavior or a stale membership is reused | Team is intra-workspace only; workspace/team composites; active workspace membership precedes team authorization | Cross-workspace team/member/owner insert and command denials; N100-04/05, N110-08 | KBN-100, 110 | Team-role policy mistakes remain possible | Controlled after evidence |
| T04 | Same-workspace IDs from a different project are combined | Planning hierarchy integrity | Valid mission/milestone/parent/current-milestone UUIDs are substituted | Project-congruent composite relations and serialized hierarchy validation | Mission/milestone/parent/current milestone mismatch and parent-cycle tests; N100-06..10, N110-09 | KBN-100, 110 | Deep hierarchy checks can be expensive | Controlled after evidence |
| T05 | Foreign or unrelated evidence/link/artifact IDs are attached | Review and audit truth | Caller has a valid same-workspace or foreign artifact UUID | Workspace-aware joins; immutable artifact digest/revision; semantic same-target validation in authoritative transaction | Mixed-workspace and same-workspace wrong-task/mission checkpoint/approval evidence tests; N100-11..14, N210-15/16 | KBN-100, 110, 210 | Same-workspace semantic validation is application-enforced | Controlled after evidence |
| T06 | Stolen, over-scoped, or replayed service token | Coordinator and task mutation authority | Service credential is accepted as admin/user or claims are trusted without DB state | Command-family least privilege; agent/session workspace binding; no raw DB credentials; enabled/live state checked per command | Auth registry fixtures prove audience/expiry/role/capability; revoked agent and ended session denials; N105-01, N110-10..13, N210-01/02 | KBN-105, 110, 210 | Credential theft until expiry/revocation check | Controlled after evidence |
| T07 | Caller forges public `healthy` or replays a stale health response | Sole-writer/fail-closed invariant | Public health body or caller field reaches mutation context | Public DTO is observation only; public DTOs reject proof/health fields; Gateway mints internal proof after live PG transaction probe | Contradictory union and forbidden-field tests; N105-02, N110-14..17 | KBN-105, 110, 140 | Health endpoint can still be used for reconnaissance | Controlled after evidence |
| T08 | Internal stale, wrong-policy, or wrong-transaction proof is reused | Transaction integrity | A branded value leaks or an adapter fails to revalidate it | Non-exported brand; transaction identity, `checkedAt <= now < validUntil`, and policy revision revalidated immediately before mutation | Wrong transaction, expiry boundary, future timestamp, policy mismatch, commit-after-expiry tests; N110-18..22 | KBN-110, 140 | In-process code can bypass TypeScript; runtime checks are mandatory | Controlled after evidence |
| T09 | DB/transport uncertainty is mislabeled as deliberate denial or conflict | Safe retry and exactly-once result | Timeout occurs before/after commit and client changes key or retries 503 | Exact 503/502/504/timeout/409 union; unknown outcome retries only with same idempotency key | Exhaustive fixture mapping and commit-before-timeout replay; N105-03, N110-23..27, N120-01/02 | KBN-105, 110, 120, 140 | External client may ignore retry rules | Controlled after evidence |
| T10 | Assignment payload forges task version, target agent/session, role, expiry, or proposer | Work routing authority | Lease service trusts command DTO rather than persisted assignment | Persisted assignment identity; exactly-one principal/proposer; exact agent/session composite; acquire accepts IDs then reloads+locks | Cross-workspace and same-workspace target substitutions, stale task version, invalid role, expired assignment; N100-15..18, N210-03..08 | KBN-100, 200, 210 | Compromised authorized proposer can make harmful proposals | Controlled by approval/audit |
| T11 | Approval proof is forged by value or borrowed from another assignment | Gate integrity | Caller submits `approved=true`, unrelated decision ID, stale policy, or self-approval | Relational approval bound to assignment; lock/reload; policy revision; author≠reviewer and mandatory SecReview | No proof-by-value DTO; wrong assignment/task/workspace/policy/actor/decision tests; N105-04, N210-09..14, N230-01 | KBN-105, 210, 230 | Colluding principals remain an organizational risk | Controlled after evidence |
| T12 | Revoked policy or expired proposal/assignment is raced against lease acquisition | Routing policy | Approval and lease transactions do not lock/revalidate current rows | Lock assignment, approval, task, target session; compare current policy and expiry inside fresh-proof transaction | Concurrent revoke/expire/acquire tests with one valid terminal result; N210-17..19 | KBN-210, 230 | Clock skew if DB time is not canonical | Controlled after evidence |
| T13 | Stale worker sends ack/heartbeat/checkpoint/review after reassignment | Canonical task and evidence state | Old process retains task/session IDs | Task-row-locked atomic monotonic bigint fence; every worker command carries exact lease/session/fence | Lower, expired, future, and other-task fences denied; old worker loses after new lease; N100-19/20, N210-20..24 | KBN-100, 210, 230 | Signed bigint exhaustion is theoretical | Controlled after evidence |
| T14 | JavaScript precision truncates a fence | Stale-worker exclusion | bigint token is serialized as number above `2^53-1` | Drizzle bigint and decimal-string wire type only | `9007199254740993` and near-`int8` boundary round trips; numeric JSON rejected; N105-05, N210-25 | KBN-105, 210 | Nonconforming external clients | Controlled after evidence |
| T15 | Checkpoint/evidence from another lease/task/session is submitted | Recovery and certification evidence | Same-workspace valid IDs are mixed | Exact lease composite binds workspace+task+assignment/session+fence; checkpoint composite binds lease+fence; evidence join plus semantic artifact-owner check | Same-workspace mismatched task/assignment/lease/session/checkpoint/artifact tests; N100-21..23, N210-26..31 | KBN-100, 210 | Artifact URI target may disappear outside DB | Controlled with digest/retention |
| T16 | Outage note or pending/rejected proposal mutates/orders work | Sole SOT and gate integrity | Importer/UI treats note/proposal as task state | Proposals are inert; only explicit accept invokes normal typed command after recovery | Row/outbox/task counts unchanged for pending/rejected; no readiness/dependency/lease effect; N110-28..31 | KBN-110, 140 | Humans may act outside Mosaic operationally | Accepted as attributable residual |
| T17 | Submission event is missing, foreign, or for another proposal | Proposal audit chain | Caller supplies an existing event UUID | Preallocated proposal ID; event-first same transaction; workspace composite FK; exact event type/aggregate/version semantic check | Missing/foreign/wrong-type/wrong-proposal event rolls back event+proposal; N100-24/25, N110-32..36 | KBN-100, 110 | Semantic checks are transaction code, not only FK | Controlled after evidence |
| T18 | Acceptance borrows an unrelated command event | Proposal and target integrity | Same-workspace event exists for another target/command/proposal | Accept locks proposal+target, executes normal command, requires workspace/target match, causation=submission event, payload proposal ID | Foreign, wrong target/type/command/causation/payload event aborts target/event/proposal atomically; N100-26, N110-37..43 | KBN-100, 110 | Event payload schema drift | Controlled by KBN-105 fixtures |
| T19 | Application role updates/deletes audit, approval evidence, checkpoint, or artifact | Nonrepudiation | Broad DB grants or parent cascade exists | INSERT/SELECT-only application roles; RESTRICT parent deletes; archive/cancel normal lifecycle | Role-level UPDATE/DELETE denied; parent delete RESTRICT; digest unchanged; N100-27..31 | KBN-100 | DB superuser can alter state | Break-glass/infra audit residual |
| T20 | Break-glass purge is used as routine deletion or erases its own evidence | Retention and incident forensics | Elevated credential available | Separate audited retention procedure, bounded scope, reason, pre/post evidence, authority separation | Normal role denied; expired/missing approval denied; purge cannot delete its authorizing audit package; N115-01, N230-02/03 | KBN-115, 230 | Privileged DBA compromise | Accepted operational residual |
| T21 | PostgreSQL unavailable or partitioned | Canonical state | Public health/Valkey remains live while transaction probe fails | Fail closed; no alternate writer/hidden queue; 503 only for proven not-applied; transport uncertainty remains unknown | Fault injection proves DB rows/outbox/files/Valkey unchanged on deliberate denial; commit-unknown replay; N110-44..48, N140-01 | KBN-110, 140, 230 | Availability loss is intentional | Accepted by Option A |
| T22 | Valkey unavailable, duplicated, stale, or partitioned | Scheduling notifications | Queue wake is treated as truth or publication fails | Valkey derived/expendable; transactional outbox in PG; idempotent publisher; recovery from PG | Commit with Valkey down leaves pending outbox; replay publishes once logically; stale wake reloads PG; N110-49, N140-02, N230-04..06 | KBN-110, 210, 230 | Duplicate at-least-once delivery | Consumers must be idempotent |
| T23 | Coordinator restarts between assignment, lease, checkpoint, or outbox steps | Durable orchestration truth | Process-local cache is treated as authority | PostgreSQL stores assignments, execution state, leases, fences, checkpoints, events, outbox; `recoverFromPostgres` | Restart at every transaction boundary reconstructs identical active/expired/pending sets without Valkey/files; N210-32..36, N230-07 | KBN-210, 230 | Recovery latency | Controlled after evidence |
| T24 | Dependency cycle or concurrent reciprocal edge | Readiness and dispatch safety | Two transactions each see an acyclic graph before inserting | Unique directed edge; no self-edge; serialized recursive cycle check; readiness evaluates all blockers | Self/duplicate/cycle and concurrent A→B/B→A tests; all predecessor property test; N100-32..35, N200-01/02 | KBN-100, 200, 230 | Very large DAG performance | Bounded operational residual |
| T25 | Parent-task cycle or project-incongruent relation | Planning hierarchy | Valid same-workspace IDs are arranged into an invalid tree | Project-congruent composites; serialized parent-cycle/orphan validation required by REQ-PLAN-001 | Self/indirect parent cycle, orphan, and cross-project mission/milestone/parent tests; N100-06..10 | KBN-100, 110 | Cycle validation is service/transaction enforced | Controlled after evidence |
| T26 | Concurrent update, duplicate retry, or idempotency payload drift | Aggregate consistency | Two clients use same version/key with different payloads | Expected-version check; semantic event and outbox in same transaction; key returns prior immutable result only for identical command | One update wins; stale gets 409; duplicate identical returns prior; payload drift rejected; N110-50..54, N140-03 | KBN-105, 110, 140 | Long-lived clients face visible conflicts | Intentional user-visible residual |
| T27 | State/event/outbox partial commit | Audit and notification consistency | Separate transactions or exception after state write | One PostgreSQL transaction for state+semantic event+outbox | Failure injected after each insert rolls all three back; success revisions align; N110-55..58 | KBN-110, 140 | Outbox publication remains asynchronous | Controlled after evidence |
| T28 | Malicious/incorrect importer injects foreign workspace data or dispatchable work | Migration integrity | Source keys collide, lineage is absent, or importer has direct DB authority | Immutable source snapshots/checksums; one-way Gateway/migration-only port; workspace-safe idempotent modes; shadow records cannot dispatch | Foreign/malformed/duplicate/partial-resume/lineage checksum and no-dispatch tests; N300-01..08 | KBN-300, 330 | Source data may be semantically ambiguous | Quarantine and owner sign-off |
| T29 | Cutover leaves legacy writer or forward/reverse sync active | Sole-writer invariant | Credentials/processes survive switch or rollback is improvised | Writer inventory, freeze, final delta, Gateway switch, credential shutdown, no dual write; rollback authority changes after first DB mutation | Process/credential inventory; concurrent-writer assertion; before/after-mutation rollback rehearsal; N320-01..06, N330-01 | KBN-320, 330, 340 | Missed external automation | Owner-gated residual |
| T30 | Generated `TASKS.md`/`mission.json` is edited or parsed into DB | Canonical state | Current-main parser/writer remains reachable or file watcher imports changes | Generated non-authoritative header/IDs/time/revision; no production importer; regenerate/overwrite only | Static import search, tamper/regeneration, read-only permission, source-revision parity; N120-03..07, N140-04 | KBN-120, 140 | Humans may mistake snapshots for live data | Header and docs mitigate |
| T31 | N-1 compatibility copies legacy ambiguity into canonical authority | Data integrity | Nullable/global/current-main fields are guessed during backfill | Nullable-first expand; deterministic mapping or quarantine; checksums; no new-only status before switch; legacy fields retained | Production-shape, ambiguous owner/assignee, status shadow, JSON/config/digest, rollback tests; N100-36..44 | KBN-100 | Quarantined records require human decision | Controlled by signed reconciliation |
| T32 | Recovery posture claims durability not provided by mechanisms | Availability and audit retention | Shape-only validation or optimistic RPO is accepted | Normative validator; WAL/PITR/RPO/storage/high-assurance constraints; mechanism and restore evidence | Unknown/impossible/weakened configuration plus actual mechanism/restore tests; N115-02..08 | KBN-115 | Backup operator or storage compromise | Separate failure domain residual |
| T33 | rc.3 frozen DDL could not create mission-scoped evidence/approval FKs | Tenant/evidence relational integrity | KBN-100 generated DDL from the rc.3 contract without an exact composite candidate key | rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` before both dependent FKs while retaining global and project-congruent keys | KBN-100 must execute N100-45..50: exact-key reconciliation, candidate-before-FKs, duplicate feasibility, empty/prod/N-1/rollback, and both-child foreign-workspace negatives | KBN-100 after PR/CI/#753 release | Runtime DDL remains unimplemented and must prove the frozen order | **Resolved by rc.4 + independent APPROVE; implementation evidence remains required** |
## 5. Constraint-impact matrix
| Impact ID | Required invariant | Frozen schema impact | API/transaction impact | Required evidence | Owner | Status |
| --------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------- |
| CI-01 | Hard workspace tenancy and no oracle | `workspace_id`, workspace-aware unique/FKs on all canonical rows | Server-derived workspace; uniform denial on all surfaces | N100-01..14; N110-01..09; N130-01 | KBN-100/105/110/130 | Resolved by frozen controls |
| CI-02 | Active user membership | Membership row plus unique `(workspace_id,user_id)`; active state retained | Recheck active membership in same authoritative transaction | N100-03; N110-06/07 | KBN-100/110 | Resolved; not FK-only |
| CI-03 | Service identity least privilege/revocation | Agent/session workspace, lifecycle, state, roles, capabilities | Token maps to exact agent/session; command-family allowlist; DB recheck; no admin/raw DB fallback | N105-01; N110-10..13; N210-01/02 | KBN-105/110/210 | Resolved at auth/API layer |
| CI-04 | Project-congruent hierarchy | Composite project/mission/milestone/parent/current-milestone relations | Lock/serialized parent-cycle and orphan validation | N100-06..10 | KBN-100/110 | Resolved; cycle behavior required |
| CI-05 | Health-proof authority | Internal branded proof has transaction/time/policy fields | Probe and revalidate on same PG transaction; no public field | N105-02/03; N110-14..27 | KBN-105/110 | Resolved by frozen controls |
| CI-06 | Assignment/approval identity | Exactly-one principal/proposer, exact agent/session assignment, relational approval | Reload+lock all IDs; compare version/target/state/expiry/policy/decision | N100-15..18; N210-03..19 | KBN-100/210 | Resolved by frozen controls |
| CI-07 | Monotonic bigint fencing | Durable bigint counter, exact lease/fence keys, one active lease | Atomic increment/RETURNING; decimal-string DTO; reject every stale worker command | N100-19..23; N210-20..31 | KBN-100/105/210 | Resolved by frozen controls |
| CI-08 | Proposal event chain | Both workspace-aware event FKs; event table created first | Exact submission/acceptance semantic checks in one transaction | N100-24..26; N110-28..43 | KBN-100/110 | Resolved; semantic checks not FK-only |
| CI-09 | Immutable audit/evidence retention | RESTRICT parents; INSERT/SELECT-only immutable tables | Archive/cancel normal flow; separately authorized purge | N100-27..31; N115-01; N230-02/03 | KBN-100/115/230 | Resolved by frozen controls |
| CI-10 | DB/Valkey/outbox/restart semantics | PG outbox and durable orchestration rows | Fail closed; same-key uncertainty retry; Valkey reloads PG; restart from PG | N110-44..49; N140-01/02; N230-04..07 | KBN-110/210/230 | Resolved by frozen controls |
| CI-11 | DAG/race/idempotency/version | Unique edge; self check; event idempotency; aggregate versions | Serialized recursive cycle check; payload binding; expected-version conflict | N100-32..35; N110-50..58; N200-01/02 | KBN-100/110/200 | Resolved by frozen controls |
| CI-12 | Import/cutover trust boundary | Lineage/artifact/event fields; shadow state cannot dispatch | One-way scoped importer, freeze, no direct DB/file authority, no dual writer | N300-01..08; N320-01..06 | KBN-300/320/330 | Resolved by frozen controls |
| CI-13 | Generated-file no-import | No canonical file schema/import contract | Projection-only package; static reachability check removes current parser from production Kanban paths | N120-03..07; N140-04 | KBN-120/140 | Resolved by frozen controls |
| CI-14 | Mission-scoped artifact and approval FKs | rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` and retains global/project-congruent keys | KBN-100 must emit the candidate before both exact RESTRICT FKs and preserve N-1/rollback order | N100-45..50: exact reconciliation, duplicate feasibility, empty/prod/N-1/rollback, and separate artifact/approval foreign-workspace negatives | KBN-100 after PR/CI/#753 release | **Resolved by rc.4 and independent APPROVE; future executable evidence required** |
## 6. Exact future negative-test catalog
These names are normative evidence identifiers for future slices. Equivalent test-file names are acceptable only if traceability retains these IDs and expected outcomes.
### KBN-100 — schema and migration
- **N100-01** reject every canonical child row whose `workspace_id` differs from its parent.
- **N100-02** reject foreign-workspace link, artifact, proposal target, dependency, assignment, lease, checkpoint, approval, and event relationships.
- **N100-03** reject an inactive/revoked member as accountable owner, proposer, decision actor, archive actor, or user principal in the authoritative command transaction.
- **N100-04** reject a team/project relation crossing workspaces.
- **N100-05** reject a team authorization path when the user lacks active membership in the team's workspace.
- **N100-06** reject task→mission project mismatch.
- **N100-07** reject task→milestone and project→current-milestone project mismatch.
- **N100-08** reject task→parent project mismatch and self-parent.
- **N100-09** reject indirect parent cycles under concurrent transactions.
- **N100-10** reject mission→milestone project mismatch/orphan.
- **N100-11** reject checkpoint artifact from another workspace.
- **N100-12** reject checkpoint artifact owned by another same-workspace task/mission unless an explicitly frozen evidence rule permits it.
- **N100-13** reject approval evidence from another workspace.
- **N100-14** reject same-workspace approval evidence unrelated to the approval target.
- **N100-15** reject zero/multiple assignment principals and zero/multiple proposers.
- **N100-16** reject target session without its exact target agent.
- **N100-17** reject assignment task/agent/session crossing workspaces.
- **N100-18** reject non-positive task version and expired assignment acquisition.
- **N100-19** concurrent lease insert permits one active lease and returns one winner.
- **N100-20** successive leases return strictly increasing bigint fences.
- **N100-21** reject checkpoint with another task, lease, or fence.
- **N100-22** reject duplicate/non-monotonic checkpoint sequence.
- **N100-23** reject evidence join for a mismatched checkpoint/task.
- **N100-24** proposal insert without exact submission event fails atomically.
- **N100-25** foreign/wrong-type/wrong-proposal submission event fails atomically.
- **N100-26** foreign/wrong-target/unrelated acceptance event fails atomically.
- **N100-27** application role cannot UPDATE/DELETE `task_events`.
- **N100-28** application role cannot UPDATE/DELETE checkpoints/artifacts/evidence joins.
- **N100-29** parent hard delete is RESTRICTed while audit/evidence children exist.
- **N100-30** archive does not alter canonical lifecycle status.
- **N100-31** purge without break-glass authority/evidence is denied.
- **N100-32** reject dependency self-edge and duplicate directed pair regardless of type.
- **N100-33** reject direct and indirect dependency cycles.
- **N100-34** concurrent reciprocal dependency inserts cannot both commit.
- **N100-35** readiness remains false until every blocking predecessor and completion condition passes.
- **N100-36** empty DB migration succeeds after the contract amendment.
- **N100-37** production-shape expand retains all legacy declarations.
- **N100-38** crash/resume backfill is idempotent and checksum-stable.
- **N100-39** ambiguous workspace/owner/assignee is quarantined, never guessed.
- **N100-40** no `ready`/`in_review` status is emitted to N-1 readers before switch.
- **N100-41** `mission_tasks.status` cannot remain a write source.
- **N100-42** tags/assignee/date/mission JSON/config/description/agent fields reconcile without loss.
- **N100-43** claimed fleet backlog rows are quarantined and imported rows cannot dispatch.
- **N100-44** pre-switch rollback works while post-first-mutation rollback requires freeze/reconciliation.
- **N100-45** reconcile both exact child FK column lists to the rc.4 `(workspace_id,id)` mission candidate while retaining the global `id` primary key and `(workspace_id,project_id,id)` key.
- **N100-46** empty-DB migration creates `missions_workspace_id_uidx` before `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk`.
- **N100-47** production-shape preflight finds no duplicate `(workspace_id,id)` groups, preserves global `id` uniqueness, and applies the candidate before both dependent FKs.
- **N100-48** N-1 startup/read/write remains unchanged; pre-switch rollback drops both dependents before the candidate and preserves the global/project-congruent keys.
- **N100-49** artifact insert using a valid mission ID paired with a foreign workspace fails before commit.
- **N100-50** approval-decision insert using a valid mission ID paired with a foreign workspace fails before commit.
### KBN-105/KBN-110/KBN-120/KBN-130/KBN-140 — API and P1
- **N105-01** every route has an explicit user/service command-family policy; user/admin tokens cannot call service-only Coordinator mutations.
- **N105-02** public DTO validation rejects `writeProof`, internal context, body `workspaceId`, and caller-asserted health.
- **N105-03** fixture exhaustiveness prevents 503, 502/504/timeout, and 409 cross-mapping.
- **N105-04** approval DTO accepts an ID and decision command only, never approval proof-by-value.
- **N105-05** all fence fields accept/emit decimal strings and reject JSON numbers.
- **N110-01** listing with a foreign `workspaceId` or foreign filter ID follows the frozen no-oracle denial and returns no rows/counts/cursors.
- **N110-02** get by foreign or nonexistent aggregate ID has the same frozen denial shape and no foreign metadata.
- **N110-03** create/update/archive with a foreign owner, parent, project, mission, milestone, tag, or target ID is denied before mutation.
- **N110-04** dependency/proposal commands with foreign target IDs are denied with unchanged state/event/outbox counts.
- **N110-05** REST, MCP, WebSocket, and internal Coordinator paths produce equivalent no-oracle behavior for the same foreign ID.
- **N110-06** a revoked/inactive owner is denied even with a still-valid Better Auth session.
- **N110-07** stale membership/team cache cannot authorize a proposer, decision actor, archive actor, or principal after revocation.
- **N110-08** a team ID from another workspace cannot authorize or own the command target.
- **N110-09** same-workspace but wrong-project mission/milestone/parent IDs are denied inside the transaction.
- **N110-10** an expired service token is denied before repository access.
- **N110-11** an audience- or workspace-mismatched service token is denied without an existence oracle.
- **N110-12** an over-scoped service token cannot call a command family absent from its role/capability allowlist.
- **N110-13** disabled agent or ended session revokes service-token command authority immediately on PostgreSQL recheck.
- **N110-14** contradictory public health state/boolean combinations fail validation.
- **N110-15** Valkey-only liveness cannot mint or substitute a PostgreSQL write proof.
- **N110-16** caller-forged public `healthy` cannot enter internal mutation context.
- **N110-17** public REST/MCP/CLI bodies containing health/proof fields are rejected.
- **N110-18** an expired internal proof produces no state/event/outbox write.
- **N110-19** a future-dated or not-yet-valid proof produces no write.
- **N110-20** a policy-revision-mismatched proof produces no write.
- **N110-21** a proof minted on another transaction/connection produces no write.
- **N110-22** a proof that expires before the final pre-mutation check produces no write.
- **N110-23** deliberate read-only/write-unavailable denial maps only to authoritative 503/not-applied/non-retryable.
- **N110-24** timeout before commit maps to transport-unknown and permits only same-key retry.
- **N110-25** timeout after commit maps to transport-unknown and same-key retry returns the committed canonical result once.
- **N110-26** expected-version mismatch maps only to 409/not-applied/non-retryable.
- **N110-27** recovery replay with a changed idempotency key cannot masquerade as the original uncertain request.
- **N110-28** pending proposal cannot alter target fields/status/rank/version.
- **N110-29** rejected proposal cannot affect readiness, dependencies, or gates.
- **N110-30** pending/rejected proposal cannot create an assignment or lease.
- **N110-31** direct proposal-row state manipulation cannot bypass normal command execution.
- **N110-32** proposal submission without a submission event rolls back fully.
- **N110-33** foreign-workspace submission event rolls back fully.
- **N110-34** wrong aggregate/event type submission event rolls back fully.
- **N110-35** same-workspace event for another proposal rolls back fully.
- **N110-36** submission event with wrong previous/new version semantics rolls back fully.
- **N110-37** foreign-workspace acceptance event rolls back proposal, target, event, and outbox.
- **N110-38** same-workspace event for another target aggregate rolls back acceptance.
- **N110-39** event from an unrelated normal command rolls back acceptance.
- **N110-40** event caused by a different submission event rolls back acceptance.
- **N110-41** event whose payload lacks or changes `changeProposalId` rolls back acceptance.
- **N110-42** event for another proposal with the same target/command rolls back acceptance.
- **N110-43** missing accepted-command event after target handling rolls back the entire transaction.
- **N110-44** read-only-degraded denial changes no DB row/outbox/file/Valkey/provider state.
- **N110-45** write-unavailable denial changes no DB row/outbox/file/Valkey/provider state.
- **N110-46** PostgreSQL disconnect cannot redirect a command to any fallback writer.
- **N110-47** commit uncertainty remains `unknown` and never becomes a fabricated 503/not-applied result.
- **N110-48** same-key replay after recovery returns one canonical result with no duplicate event/outbox row.
- **N110-49** Valkey publication failure leaves committed PG outbox pending and replayable.
- **N110-50** two same-version updates produce one winner and one visible 409 loser.
- **N110-51** identical duplicate key+payload returns the prior immutable result without another event/outbox row.
- **N110-52** same key with payload/command drift is rejected as an idempotency conflict.
- **N110-53** the same key in another workspace cannot reveal or reuse the first workspace's result.
- **N110-54** stale reconnect/update cannot silently overwrite a newer aggregate revision.
- **N110-55** failure after state write but before semantic event rolls back state.
- **N110-56** failure after semantic event but before outbox rolls back state and event.
- **N110-57** failure after outbox insert but before commit rolls back state, event, and outbox.
- **N110-58** success commits matching aggregate/event/outbox revisions and correlation/causation.
- **N120-01** CLI never retries an authoritative 503 deliberate denial.
- **N120-02** CLI retries only transport-unknown outcomes and preserves the exact idempotency key.
- **N120-03** generated projection header contains non-authoritative warning, workspace/project IDs, generated time, and source revision.
- **N120-04** projection revision and records match the API snapshot revision exactly.
- **N120-05** hand-tampering is overwritten or rejected by regeneration and never mutates PostgreSQL.
- **N120-06** static/runtime reachability finds no parser/import path from `TASKS.md`, `mission.json`, or another export.
- **N120-07** projection writer has no domain mutation/raw SQL/Valkey authority.
- **N130-01** UI foreign/no-access/not-found state follows the frozen no-oracle response and renders no stale foreign data.
- **N140-01** real-Gateway DB fault journey proves fail-closed no-fallback behavior.
- **N140-02** real-Gateway Valkey-loss journey proves pending outbox replay.
- **N140-03** real-Gateway concurrent update/retry journey proves version and idempotency semantics.
- **N140-04** generated-file tamper journey proves projection parity and no import.
### KBN-115/KBN-200/KBN-210/KBN-230 — recovery and coordination
- **N115-01** retention purge without current break-glass authority, reason, immutable evidence, or bounded scope is denied and audited.
- **N115-02** recovery posture with an unknown top-level or storage field is rejected.
- **N115-03** PITR retention without WAL archival is rejected.
- **N115-04** WAL archival with zero PITR retention is rejected.
- **N115-05** claimed RPO better than the configured backup/WAL mechanism is rejected.
- **N115-06** unencrypted, optional, or same-failure-domain storage is rejected.
- **N115-07** weakened high-assurance values are rejected.
- **N115-08** shape-only validation cannot pass without normative mechanism and restore evidence.
- **N200-01** cyclic/incomplete dependency snapshots never become eligible.
- **N200-02** identical immutable snapshot+policy+time returns identical ordering and explanation with no I/O/model import.
- **N210-01** disabled agent cannot claim, ack, heartbeat, checkpoint, or submit review.
- **N210-02** ended/offline/mismatched session cannot claim, ack, heartbeat, checkpoint, or submit review.
- **N210-03** foreign-workspace task is rejected after lock/reload without an oracle.
- **N210-04** stale task version is rejected before fence increment.
- **N210-05** assignment target agent mismatch is rejected.
- **N210-06** target session mismatch is rejected.
- **N210-07** expired assignment is rejected.
- **N210-08** assignment in rejected/released/expired/superseded/leased-invalid state is rejected.
- **N210-09** missing approval is rejected.
- **N210-10** rejected/escalated/requested approval is rejected as approval authority.
- **N210-11** stale policy-revision approval is rejected.
- **N210-12** foreign-workspace approval is rejected without an oracle.
- **N210-13** approval for another assignment is rejected.
- **N210-14** author self-approval/review is rejected when independence is required.
- **N210-15** foreign-workspace artifact evidence is rejected.
- **N210-16** same-workspace artifact unrelated to the assignment/task/gate is rejected.
- **N210-17** concurrent policy revocation versus acquire cannot produce a lease under the revoked revision.
- **N210-18** concurrent assignment expiry versus acquire cannot produce a lease after expiry.
- **N210-19** concurrent session end versus acquire cannot produce a lease for the ended session.
- **N210-20** lower fencing token is rejected without writes.
- **N210-21** token from an older lease is rejected without writes.
- **N210-22** token paired with another task is rejected without writes.
- **N210-23** token paired with another session is rejected without writes.
- **N210-24** token on an expired/revoked/released lease is rejected without writes.
- **N210-25** fences above JavaScript safe integer round-trip exactly as decimal strings.
- **N210-26** lease task does not match assignment task and is rejected.
- **N210-27** lease agent/session does not match assignment target and is rejected.
- **N210-28** checkpoint task does not match lease task and is rejected.
- **N210-29** checkpoint fence does not match exact lease fence and is rejected.
- **N210-30** checkpoint sequence duplicate/regression is rejected.
- **N210-31** checkpoint artifact does not match workspace/task/evidence semantics and is rejected.
- **N210-32** restart after assignment persistence reconstructs the pending assignment.
- **N210-33** restart after lease commit reconstructs exact active lease and fence.
- **N210-34** restart after checkpoint commit reconstructs checkpoint/recovery state.
- **N210-35** restart during expiry/retry/quarantine reconstructs durable disposition and eligibility.
- **N210-36** restart with pending outbox reconstructs publication work without Valkey/files.
- **N230-01** author=self-review and missing mandatory SecReview cannot certify or complete.
- **N230-02** normal application role cannot execute retention purge.
- **N230-03** break-glass purge cannot delete or alter its own authorization/evidence chain.
- **N230-04** Valkey down leaves canonical work in PostgreSQL/outbox.
- **N230-05** duplicate wake produces one logical effect after PostgreSQL reload/idempotency.
- **N230-06** stale wake cannot revive an expired/revoked assignment or lease.
- **N230-07** restart with no Valkey/files reconstructs leases/retry/quarantine/outbox exactly.
### KBN-300/KBN-320/KBN-330/KBN-340 — migration and cutover
- **N300-01** source record targeting another workspace is denied/quarantined without an oracle.
- **N300-02** malformed source record is rejected with attributable reject evidence.
- **N300-03** duplicate source system/key/batch replay is idempotent.
- **N300-04** source snapshot/checksum drift aborts apply/verify.
- **N300-05** partial import resumes from durable lineage without duplicating state/events.
- **N300-06** imported shadow record cannot become ready, assigned, or leased automatically.
- **N300-07** missing source key/file/checksum/batch lineage prevents apply/sign-off.
- **N300-08** importer cannot use direct DB, generated file, Valkey, or provider issue as canonical write authority.
- **N320-01** cutover without a verified write freeze fails safe.
- **N320-02** active legacy writer process or credential blocks cutover.
- **N320-03** reverse and forward synchronization cannot run concurrently.
- **N320-04** failed final delta/reconciliation blocks client switch.
- **N320-05** rollback before first canonical DB mutation may switch authority back only after freeze assertion.
- **N320-06** rollback after first canonical mutation requires freeze, DB-delta export/reconciliation, and owner decision.
- **N330-01** rehearsal cannot sign off while counts/checksums/exceptions/writer inventory differ.
- **N340-01** cutover cannot proceed without owner authorization, terminal evidence, scoped identities, and zero active legacy writers.
## 7. Requirements traceability
| Requirement | Threats/impacts | Planned evidence |
| ---------------- | ---------------------------- | --------------------------------------------------------------- |
| REQ-SOT-001 | T16, T21, T22, T27, T29, T30 | N110-28..31, N110-44..49, N110-55..58, N120-03..07, N320-01..06 |
| REQ-SOT-002 | T07, T08, T09, T21 | N105-02/03, N110-14..27, N110-44..48 |
| REQ-SOT-003 | T30 | N120-03..07, N140-04 |
| REQ-SOT-004 | T16..18 | N100-24..26, N110-28..43 |
| REQ-TEN-001 | T01..05, T15, T33 | N100-01..14, N100-45..50, N110-01..09, N210-15/16 |
| REQ-ID-001 | T02, T03, T06, T10..12 | N105-01, N110-06..13, N210-01..19 |
| REQ-PLAN-001 | T04, T25 | N100-06..10 |
| REQ-TASK-001 | T13, T26, T31 | N100-20, N100-37..42, N110-50..54 |
| REQ-TASK-002 | T16, T24 | N110-28..31, N100-35, N200-01 |
| REQ-DEP-001 | T24 | N100-32..35, N200-01 |
| REQ-ASN-001 | T10..12 | N100-15..18, N210-03..19 |
| REQ-AUD-001 | T17..20, T22, T27 | N100-24..31, N110-32..43, N110-49, N110-55..58 |
| REQ-API-001 | T01, T06..18, T26 | N105-01..05 plus KBN-110 catalog |
| REQ-UI-002/003 | T01, T15, T26 | N130-01 and real-Gateway KBN-140 journeys |
| REQ-COORD-001 | T22..24 | N200-01/02, N210-32..36 |
| REQ-COORD-002 | T10..12, T16 | N210-03..19, N110-28..31 |
| REQ-COORD-003 | T13..15, T23 | N100-19..23, N210-20..36 |
| REQ-COORD-004 | T23, T26 | N210-32..36, N230-07 |
| REQ-GATE-001/002 | T11, T19, T20 | N210-09..14, N230-01..03 |
| REQ-REC-001 | T20, T32 | N115-01..08 |
| REQ-MIG-001/002 | T28, T29, T31 | N100-37..44, N300-01..08, N320-01..06, N330-01, N340-01 |
REQ-UI-001 and REQ-UI-004 are downstream functional/accessibility requirements rather than schema-threat controls; they remain owned by KBN-130/KBN-140. Their security-relevant tenancy, conflict, and stale-reconnect portions are covered above.
## 8. Issue #753 acceptance mapping
| Issue requirement/criterion | Evidence in this document | Result |
| --------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
| Cross-workspace owners, principals, evidence, project hierarchy | T01T05, T15, T25; CI-0104 | Mapped |
| Active membership and service-token boundaries | Authorization matrix; T02, T03, T06; CI-02/03 | Mapped |
| Stale/forged health and transaction-local proof | T07T09, T21; CI-05 | Mapped |
| Assignment/approval forgery and monotonic fencing | T10T15; CI-06/07 | Mapped |
| Change-proposal abuse and event binding | T16T18; CI-08 | Mapped |
| Immutable audit and break-glass | T19/T20; CI-09 | Mapped |
| PostgreSQL/Valkey failures | T21T23; CI-10 | Mapped |
| Dependency/idempotency/version races | T24T27; CI-11 | Mapped |
| Import/cutover and generated-file boundary | T28T31; CI-12/13 | Mapped |
| Every schema/API/test impact explicit | Constraint matrix and negative-test catalog | Mapped |
| No unresolved schema impact | CI-14; rc.4 resolved-impact record | **PASS — none unresolved** |
| Independent SecReview | Homelab non-author exact commit/tree/content review | **PASS / APPROVE** |
| PR merge, terminal-green main CI, and #753 closure | Orchestrator-owned post-worker gates | Pending; KBN-100 remains held until completion |
## 9. UNRESOLVED SCHEMA IMPACTS
none
### Resolved-impact record — KBN010-SI-001
- **Historical detection:** rc.3 lacked an exact `(workspace_id,id)` candidate key for the artifact and approval-decision mission FKs. This document's original BLOCKED verdict was correct and remains preserved in §1 and T33.
- **Resolution:** rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` before both exact dependent FKs while retaining the global primary key and project-congruent key.
- **Reviewed object:** commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`.
- **Corroborating identities:** full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`; stable patch-id `058cf98026fcd1043703c866aee047c8bb144740`.
- **Independent verdict:** Homelab non-author schema/security review **APPROVE**. It confirmed PostgreSQL candidate/FK validity, unchanged tenant and polymorphic exactly-one-target safety, RESTRICT/no-cascade semantics, N-1/rollback validity, and no shared table/index/FK/identity/fence authority collision with #757.
- **Digest interpretation:** a command-rendered patch digest varied with rendering command/options and is non-authoritative. Git commit + tree + exact file content are canonical; stable full-index SHA-256 and stable patch-id corroborate that identity.
- **Residual implementation obligations:** KBN-100 must create the candidate before both dependent FKs; prove production-shape duplicate feasibility without weakening global uniqueness; pass empty/prod/N-1/rollback tests; reconcile both exact FK targets; and separately reject foreign-workspace mission references for artifacts and approval decisions (N100-45..50).
- **Implementation status:** no runtime schema, migration, API, or deployment implementation is claimed by this gate disposition.
## 10. Residual risk and handoff
- Active membership, polymorphic targets, same-task evidence semantics, parent/DAG cycle checks, token scope, and no-oracle behavior depend on authoritative transaction code and must not be treated as FK-only guarantees.
- DB superuser and break-glass compromise cannot be eliminated by application constraints; separation of duties, immutable external backup/audit evidence, drills, and monitoring remain required.
- PostgreSQL unavailability intentionally sacrifices writes for integrity. Transport-unknown outcomes remain safe only when clients preserve the exact idempotency key.
- Imported ambiguous records remain quarantined until owner sign-off; no automated mapping may convert ambiguity into authority.
- SI-001 is resolved at frozen contract/design-review level only. KBN-100 still owes N100-45..50 executable migration evidence.
**Handoff status:** KBN-010 **PASS / GO** at rc.4. KBN-100 remains held until this PR squash-merges, terminal-green CI completes on `main`, and issue #753 closes; the orchestrator owns those remaining gates.

View File

@@ -1,9 +1,21 @@
# Native Kanban/SOT — Remediated Shared Contract v1 # Native Kanban/SOT — Remediated Shared Contract v1
**Status:** INDEPENDENT REVIEW GO; freezes as v1 when issue #751 canon merges to `main` **Status:** CONTROL-PLANE SI-001 AMENDMENT AUTHORIZED; prior KCR-001016 independent-review GO retained; rc.4 requires independent schema/SecReview before KBN-100
**Version:** 1.0.0-rc.3 **Version:** 1.0.0-rc.4
**Date:** 2026-07-14 **Date:** 2026-07-14
**Change authority:** Mosaic control plane/Jason only **Change authority:** Mosaic control plane/Jason only
**SI-001 amendment authority:** `web1:mosaic-100` control-plane decision under issue #753
## Amendment record
### 1.0.0-rc.4 — KBN010-SI-001
- **Choice:** add the explicitly named, non-partial unique candidate key `missions_workspace_id_uidx` on `missions(workspace_id, id)` and retain `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`.
- **Rationale:** mission `id` remains globally unique, while the composite candidate key makes the frozen tenant-safe generic mission relations valid. `artifacts` and `approval_decisions` are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would unnecessarily broaden v1 and its target semantics.
- **Exact effect:** `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` continue to reference the exact ordered columns `missions(workspace_id, id)` with RESTRICT deletion, now backed by a matching candidate key.
- **Non-effect:** no SOT, tenancy, project-congruence, proposal-audit, approval, fencing, immutability, no-cascade, API, or wire-version invariant changes. The `SuccessEnvelopeV1.contractVersion` remains `1.0.0`.
- **Historical evidence boundary:** `KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` intentionally remains the immutable rc.3 blocker verdict that detected SI-001; this rc.4 record and the #753 scratchpad append are the authorized disposition. Rewriting the gate verdict is outside this amendment's exclusive scope.
- **Gate:** this amendment resolves the DDL defect identified by KBN010-SI-001 but does not itself lift KBN-100; independent schema/SecReview remains required.
## 1. Authority ## 1. Authority
@@ -43,6 +55,7 @@ Complete declaration: `contracts/kanban-schema.v1.ts`.
- Specialist roles everywhere: `planning | enhance | coder | review | security-review | pr-monitor | certifier`. - Specialist roles everywhere: `planning | enhance | coder | review | security-review | pr-monitor | certifier`.
- Owner uses exactly-one user/team; assignment principal exactly-one user/team/agent; users require active membership; agent/session and all evidence are workspace-bound. - Owner uses exactly-one user/team; assignment principal exactly-one user/team/agent; users require active membership; agent/session and all evidence are workspace-bound.
- Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations. - Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations.
- Mission `id` remains globally unique. The additional non-partial `missions_workspace_id_uidx` candidate key on `(workspace_id, id)` exists only to support the frozen workspace-safe polymorphic artifact and approval-decision mission relations; the project-congruent `(workspace_id, project_id, id)` key remains authoritative wherever `project_id` is present.
- Dependency identity is workspace+predecessor+successor independent of type. - Dependency identity is workspace+predecessor+successor independent of type.
- Approval evidence and checkpoint evidence are workspace-scoped joins to immutable artifacts, never JSON ID arrays. - Approval evidence and checkpoint evidence are workspace-scoped joins to immutable artifacts, never JSON ID arrays.
- Proposal audit links are composite relations: `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` reference `task_events(workspace_id, id)` with RESTRICT deletion. - Proposal audit links are composite relations: `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` reference `task_events(workspace_id, id)` with RESTRICT deletion.
@@ -76,7 +89,20 @@ Legacy columns remain declared in unified `schema.ts` for expand + full N-1/roll
6. **Switch:** stop N-1 writers; Gateway sole command boundary; enable canonical statuses. 6. **Switch:** stop N-1 writers; Gateway sole command boundary; enable canonical statuses.
7. **Contract release:** later release after rollback/N-1; remove compatibility/global uniques/legacy fields. 7. **Contract release:** later release after rollback/N-1; remove compatibility/global uniques/legacy fields.
### 5.2 New audit/proposal DDL order ### 5.2 Mission candidate-key and dependent-FK DDL order
KBN-100 migration DDL must execute the SI-001 portion in this order:
1. expand/backfill `missions.workspace_id` and `missions.project_id` while preserving the global `missions.id` primary key and the project-congruent `missions_workspace_project_id_uidx` key;
2. prove duplicate-key feasibility on the production-shape dataset: `(workspace_id, id)` has no duplicate groups and global `id` uniqueness remains intact;
3. create the non-partial unique index `missions_workspace_id_uidx` on exact ordered columns `(workspace_id, id)`;
4. only after step 3, create/alter `artifacts` and add `artifacts_workspace_mission_fk` from `(workspace_id, mission_id)` to exact `missions(workspace_id, id)` with `ON DELETE RESTRICT`;
5. only after step 3, create/alter `approval_decisions` and add `approval_decisions_workspace_mission_fk` from `(workspace_id, mission_id)` to exact `missions(workspace_id, id)` with `ON DELETE RESTRICT`;
6. validate both constraints and prove a mission ID paired with a foreign workspace is rejected for each child.
The candidate key is intentionally redundant with globally unique `missions.id`, but PostgreSQL requires a matching unique candidate key for the exact two-column FK target. It is additive and N-1-safe. Pre-switch rollback drops the two dependent FKs/tables before dropping this candidate key, preserves the global primary key and project-congruent key, and follows the existing freeze/reconciliation rule after the first canonical mutation.
### 5.3 New audit/proposal DDL order
KBN-100 migration DDL must execute in this order: KBN-100 migration DDL must execute in this order:
@@ -88,36 +114,39 @@ KBN-100 migration DDL must execute in this order:
The submission transaction inserts the event first using a preallocated proposal UUID, then the proposal. Acceptance inserts the normal command event before updating the locked proposal. Neither FK is omitted or replaced by a bare UUID/index check. The submission transaction inserts the event first using a preallocated proposal UUID, then the proposal. Acceptance inserts the normal command event before updating the locked proposal. Neither FK is omitted or replaced by a bare UUID/index check.
### 5.3 Field map ### 5.4 Field map
| Current | Expand/backfill | N-1 compatibility | Switch/contract | | Current | Expand/backfill | N-1 compatibility | Switch/contract |
| ---------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- | | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------- |
| global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later | | global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later |
| `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later | | `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later |
| project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity | | project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity |
| current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority | | current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority |
| nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later | | nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later |
| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review | | mission relational candidate keys | retain global `id` PK and project-congruent key; add non-partial `(workspace_id,id)` key before artifact/approval FKs | additive key is ignored safely by N-1 readers/writers | retain both composite keys; generic mission children use exact workspace+ID target |
| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority | | mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review |
| mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off | | `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority |
| mission config/metadata/phase/user | retain all; map known typed policy only | all remain declared | remove only by signed consumer inventory | | mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off |
| nullable `tasks.project_id` | derive explicit/mission project; orphan quarantine | retain nullable read/write during compatibility | canonical required; NOT NULL later | | mission config/metadata/phase/user | retain all; map known typed policy only | all remain declared | remove only by signed consumer inventory |
| `tasks.mission_id` | add project-congruent composite | old relation readable | composite authority | | nullable `tasks.project_id` | derive explicit/mission project; orphan quarantine | retain nullable read/write during compatibility | canonical required; NOT NULL later |
| `tasks.status` | canonical: not-started→backlog, in-progress→in_progress, others same | no ready/in_review emission | canonical authority | | `tasks.mission_id` | add project-congruent composite | old relation readable | composite authority |
| `tasks.assignee` | deterministic active user/team/agent assignment; raw value preserved if ambiguous | mirror text only if unambiguous | canonical owner/assignment; remove after no-loss sign-off | | `tasks.status` | canonical: not-started→backlog, in-progress→in_progress, others same | no ready/in_review emission | canonical authority |
| `tasks.tags` JSON | normalize trim/case/dedupe with original digest | transactionally mirror normalized rows | normalized authority; JSON later removed | | `tasks.assignee` | deterministic active user/team/agent assignment; raw value preserved if ambiguous | mirror text only if unambiguous | canonical owner/assignment; remove after no-loss sign-off |
| `tasks.due_date` | copy exactly to `due_at` | mirror | due_at authority; legacy later | | `tasks.tags` JSON | normalize trim/case/dedupe with original digest | transactionally mirror normalized rows | normalized authority; JSON later removed |
| task common fields | preserve metadata byte-for-byte; add criteria/rank/retry/archive/version/fence | old reads valid | new fields canonical | | `tasks.due_date` | copy exactly to `due_at` | mirror | due_at authority; legacy later |
| `mission_tasks.status` | keep; prohibit as write source; linked status ignored; unlinked becomes task or reject | read-only compatibility value | membership uses task mission; status dropped after no readers | | task common fields | preserve metadata byte-for-byte; add criteria/rank/retry/archive/version/fence | old reads valid | new fields canonical |
| mission-task notes/PR/user | map to metadata/artifact/event/link/attribution; preserve | read-only | remove after parity | | `mission_tasks.status` | keep; prohibit as write source; linked status ignored; unlinked becomes task or reject | read-only compatibility value | membership uses task mission; status dropped after no readers |
| `agents.status` | add workspace/lifecycle/runtime/roles; status remains presence | retain all legacy fields | lifecycle/roles authority; status may remain telemetry | | mission-task notes/PR/user | map to metadata/artifact/event/link/attribution; preserve | read-only | remove after parity |
| agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory | | `agents.status` | add workspace/lifecycle/runtime/roles; status remains presence | retain all legacy fields | lifecycle/roles authority; status may remain telemetry |
| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization | | agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory |
| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization |
### 5.4 Required migration tests ### 5.5 Required migration tests
Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release. Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release.
SI-001 adds frozen future executable evidence: empty and production-shape migrations create `missions_workspace_id_uidx` before either dependent FK; duplicate-key feasibility preflight returns no `(workspace_id,id)` duplicate groups without weakening global `id` uniqueness; N-1 startup/read/write behavior is unchanged; pre-switch rollback removes dependents before the candidate key; both exact FK column lists reconcile to the candidate key; and foreign-workspace mission references fail for both artifacts and approval decisions. TDD is not applicable to this design-only amendment; KBN-100 must implement these negative migration tests before runtime schema release.
Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation. Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation.
## 6. Ownership and Coordinator split ## 6. Ownership and Coordinator split
@@ -216,4 +245,10 @@ KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recove
Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority. Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
The build hold remains active until independent re-review reports GO for KCR-001016. Mos alone releases waves and serializes integration roots. ### 9.1 SI-001 amendment gate and #757 boundary
- KBN-100 must provide the §5.2 candidate-key ordering, duplicate-feasibility, exact-FK reconciliation, empty/prod/N-1/rollback, and two-child foreign-workspace evidence before SI-001 can be certified closed.
- All prior KCR-001016 decisions and fixed SOT/tenant/authority, proposal-audit, approval, task-fencing, immutability, and no-cascade invariants remain unchanged.
- Read-only PR #757 cross-check: its logical-agent connector lease/CAS fencing uses separate runtime tables/contracts and `lease_epoch`; rc.4 changes only the frozen `missions` candidate key. There is no shared table, index, FK, identity, fence, or authority semantic to consume or reconcile, and #757 remains owned by its existing lane.
The build hold remains active until independent re-review reports GO for KCR-001016 and the rc.4 SI-001 amendment. Mos alone releases waves and serializes integration roots.

View File

@@ -70,19 +70,21 @@ No consumer implementation begins before KBN-105. No schema work begins before K
### KBN-000 — Remediate and publish canon ### KBN-000 — Remediate and publish canon
- **Owner:** Mos / publication control plane. - **Status:** COMPLETE — PR #752 squash-merged as `49e8a54`; issue #751 closed; post-merge pipeline #1798 terminal success.
- **Mode:** SERIAL; publication gate in progress. - **Owner:** Mosaic publication control plane.
- **Mode:** SERIAL; completed.
- **IN:** Resolve KCR-001016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review. - **IN:** Resolve KCR-001016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review.
- **OUT:** Feature implementation. - **OUT:** Feature implementation.
- **Depends on:** none. - **Depends on:** none.
- **Contract surfaces:** all canon. - **Contract surfaces:** all canon.
- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO. - **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO; Ultron GO; terminal-green CI.
### KBN-010 — Threat, authorization, and constraint-impact gate ### KBN-010 — Threat, authorization, and constraint-impact gate
- **Owner:** coder3; independent `secrev`. - **Status:** IN PROGRESS — issue [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753).
- **Owner:** `kbn-coder3`; independent `secrev`.
- **Mode:** SERIAL prerequisite of KBN-100. - **Mode:** SERIAL prerequisite of KBN-100.
- **Exclusive files:** Mos-selected threat/auth docs only. - **Exclusive files:** `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and task scratchpad only.
- **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage. - **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage.
- **OUT:** Runtime/schema edits. - **OUT:** Runtime/schema edits.
- **Depends on:** KBN-000 independent re-review GO. - **Depends on:** KBN-000 independent re-review GO.

View File

@@ -485,6 +485,7 @@ export const missionsV1 = pgTable(
foreignColumns: [projectsV1.workspaceId, projectsV1.id], foreignColumns: [projectsV1.workspaceId, projectsV1.id],
}).onDelete('restrict'), }).onDelete('restrict'),
uniqueIndex('missions_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id), uniqueIndex('missions_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id),
uniqueIndex('missions_workspace_id_uidx').on(t.workspaceId, t.id),
index('missions_workspace_project_status_idx').on( index('missions_workspace_project_status_idx').on(
t.workspaceId, t.workspaceId,
t.projectId, t.projectId,

View File

@@ -0,0 +1,27 @@
# Independent Code Review — #756 Official Discord Channel Plugin
**Verdict: APPROVE**
## Scope reviewed
Complete current uncommitted #756 delta: multi-binding trusted agent selection, privileged ingress-route validation, attachment validation/persistence/resume, egress route lifecycle and idempotency, concurrent gateway stream state, Discord lifecycle/thread/rate behavior, compatibility ingress, and tests.
## Review result
No blocking or change-request finding remains.
- **Trusted multi-agent routing:** each binding requires a provisioned `agentConfigId`; the gateway resolves that config server-side, verifies its logical-agent name, and never accepts a Discord-controlled agent selection or applies generic routing to Discord ingress.
- **Auth and route integrity:** allowlist, pairing, role, and canonical logical-agent/channel-or-thread conversation-route validation occur before gateway processing. Privileged approval/stop paths use the same binding and route validation.
- **Attachments:** ingress rejects malformed, over-bounded, credential-bearing, fragment-bearing, or query-bearing URLs. Valid attachment metadata, including `sizeBytes`, persists and is reconstructed into resume history as explicitly untrusted context.
- **Discord reliability:** the adapter supports degraded Socket.IO reconnect, parent/thread routing semantics, bounded pre-side-effect ingress rates, and terminal response-route cleanup. Egress validates route/message alignment, sends deterministic nonces, distinguishes permanent from transient errors, and uses bounded retries.
- **Concurrent state:** per-client/conversation keys isolate listener, redaction, tool, and stream state for simultaneous threads sharing a Discord socket; disconnect cleanup covers all associated conversations.
- **Harness neutrality:** contracts retain logical-agent/channel data only; no harness/provider identity leaks into adapter routes or message boundaries.
## Verification performed
- `git diff --check` — passed.
- `pnpm --filter @mosaicstack/discord-plugin typecheck` — passed.
- `pnpm --filter @mosaicstack/discord-plugin lint` — passed.
- `pnpm --filter @mosaicstack/discord-plugin test` — passed: 44 tests; coverage 92.18% statements/lines, 86.55% branches, 100% functions (all ≥85% threshold).
- `pnpm --filter @mosaicstack/gateway typecheck` — passed.
- `cd apps/gateway && pnpm exec vitest run src/plugin/discord-ingress.security.spec.ts src/chat/chat.gateway-redaction.spec.ts src/__tests__/integration/tess-cross-surface.integration.test.ts` — passed: 32 tests.

View File

@@ -0,0 +1,36 @@
# Documentation Completion Checklist — #756 Official Discord plugin
## Required artifacts
- [x] `docs/PRD.md` includes the #756 workstream, assumptions, and acceptance criteria.
- [x] User workflow updated in `docs/tess/USER-GUIDE.md`.
- [x] Administrator configuration and authorization policy updated in `docs/guides/admin-guide.md`.
- [x] Developer/plugin authoring guidance updated in `docs/tess/PLUGIN-GUIDE.md`.
- [x] Channel architecture updated in `docs/architecture/channel-protocol.md`.
- [x] Package operations/development guide added at `plugins/discord/README.md`.
- [x] `docs/SITEMAP.md` links the official channel plugin documentation.
## API coverage
- [x] No HTTP or WebSocket endpoint was added, removed, or changed.
- [x] No OpenAPI update is needed.
- [x] Shared TypeScript contracts are documented in architecture and plugin-authoring guides.
- [x] Discord authentication, authorization, thread failure, and control-command behavior are documented.
## Structural standards
- [x] Working notes remain under `docs/scratchpads/`.
- [x] Review and checklist artifacts remain under `docs/reports/`.
- [x] No generated publishing output was added.
- [x] Existing repository documentation structure was preserved; no unrelated root cleanup was attempted.
## Review gate
- [x] Independent documentation/contract review passes (shared-contract review plus final code review of the current documentation delta).
- [x] Independent code review verifies documentation matches implementation (`docs/reports/code-review/756-code-review.md`: APPROVE).
- [x] Independent security review verifies documented controls (`docs/reports/security/756-security-review.md`: APPROVE).
## Publishing
- [x] Canonical source remains in-repository.
- [x] No external publishing action is in scope for this slice.

View File

@@ -0,0 +1,37 @@
# Security Review — Issue #756
**Scope:** final current uncommitted Discord plugin, shared channel contract, gateway ingress, AgentService, and plugin registration delta
**Snapshot:** `plugins/discord/src/index.ts` SHA-256 `5ee6b6aa4e2ff349f137f76b918d02c1254e12066cb48b136048e57bef36fdb2`
**Verdict:** **APPROVE**
## Final remediation verification
| Area | Current evidence | Result |
|---|---|---|
| Attachment confidentiality and integrity | Ingress accepts bounded attachment metadata only when URL is HTTPS, query-free, fragment-free, and credential-free. Count, aggregate size, field length, MIME, and finite non-negative size validation apply before dispatch; `sizeBytes` is signed and retained. | Pass |
| Trusted agent selection | Each binding names a required trusted `agentConfigId`; gateway resolves that config server-side and requires its configured name to equal the binding logical-agent ID. Client/provider input cannot select the agent for Discord ingress. | Pass |
| Privileged operation routing | Gateway revalidates the binding and requires the signed conversation identity to match the configured logical agent before approve/stop actions. Paired admin identity and one-time approval checks remain enforced. | Pass |
| Egress containment and delivery | Egress requires an aligned message/route, configured parent or exact observed thread target, and cleans response routes after completion, errors, typed-ingress failure, or bounded-map pressure. | Pass |
| Side-effect limits | Per guild/authorized-parent/user rolling message and thread limits execute before thread creation or dispatch. | Pass |
## Security controls reviewed
- Default-deny guild, parent-channel, user, configured pairing, and role checks precede rate consumption and all side effects.
- Gateway independently enforces Discord service authentication, HMAC integrity, allowlists, binding/role checks, attachment validation, and replay-ID rejection.
- Thread authorization uses only the Discord thread parent; category parents cannot authorize ingress.
- Agent-visible attachment references are explicitly labeled untrusted; binary content is not embedded. Persisted attachment name/URL values are redacted.
- Egress uses deterministic nonces, bounded transient retry, typed terminal errors, and does not send to forged targets.
- No secrets or message content were added to plugin logs.
## Verification evidence
| Command | Result |
|---|---|
| `pnpm --filter @mosaicstack/discord-plugin test` | PASS — 44 tests; v8 coverage: 92.18% statements/lines, 86.55% branches, 100% functions |
| `pnpm --filter @mosaicstack/discord-plugin typecheck` | PASS |
| `pnpm --filter @mosaicstack/types typecheck` | PASS |
| `pnpm --filter @mosaicstack/gateway typecheck` | PASS |
| `pnpm --filter @mosaicstack/gateway exec vitest run src/plugin/discord-ingress.security.spec.ts src/agent/__tests__/agent-service-ownership.test.ts` | PASS — 29 tests |
| `git diff --check` | PASS |
No unresolved critical, high, medium, or low security findings were identified in the reviewed final delta.

View File

@@ -0,0 +1,101 @@
# Issue #753 — KBN-010 threat, authorization, and constraint-impact gate
## Objective
Complete the mandatory threat/auth/constraint-impact analysis that gates KBN-100 schema implementation.
## Scope
- In: threat matrix, frozen-control mapping, schema/API/test impact inventory, security evidence plan.
- Out: runtime, schema, migration, API, dependency, CI, deployment, and secret changes.
- Canonical requirements: `docs/requirements/native-kanban-sot.md`.
- Frozen contract: `docs/native-kanban-sot/SHARED-CONTRACT.md` and `contracts/*.v1.ts`.
- Tracking: Mosaic Stack issue #753.
## Plan
1. Independently inspect current-main implementation and frozen canon.
2. Enumerate tenant, identity, health-proof, approval, fencing, proposal, audit, token, and outage threats.
3. Map every threat to required constraints, command behavior, negative tests, and owning future slice.
4. Surface any unresolved schema impact as a blocker; do not silently amend the frozen contract.
5. Run documentation/static validation and submit for independent SecReview.
## Execution log
- 2026-07-14: KBN-000 completed through PR #752 and post-merge pipeline #1798.
- 2026-07-14: KBN-010 issue #753 created; task marked in progress; fresh GPT worker pending dispatch.
## Verification evidence
Pending worker validation, independent SecReview, Ultron gate, PR merge, post-merge CI, and issue closure.
## 2026-07-14 worker analysis checkpoint
- Inspected issue #753, canonical requirements, all frozen v1 contracts, and actual `origin/main` at `49e8a54` across DB schema, Better Auth scope, project/task/mission/team repositories/controllers, fleet backlog, and `TASKS.md` parsing/writing.
- Confirmed the worker branch has no source/runtime/schema delta from `origin/main`; orchestrator-owned `.mosaic` state remains dirty and untouched.
- Authored the threat, authorization, constraint-impact, negative-test, and requirements-traceability analysis in `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md`.
- Gate decision: **BLOCKED** by `KBN010-SI-001`. `missionsV1` lacks a unique candidate key on `(workspace_id, id)`, while `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` both reference that exact pair. PostgreSQL cannot create the frozen composite foreign keys as declared.
- Decision: do not select or apply a schema fix. Contract authority must version either a `(workspace_id, id)` mission candidate key or project-congruent child keys, then obtain independent re-review before KBN-100.
- Additional risks are controlled by frozen transaction/API behavior but require the exact future negative tests cataloged in the deliverable, especially active membership, service-token revocation, same-workspace semantic evidence checks, no-oracle behavior, and serialized parent/DAG cycle checks.
- OpenBrain startup recall was attempted but unavailable because `/home/hermes/.config/mosaic/credentials.json` is absent; no project state was written to an alternate memory silo.
- TDD: not applicable; this slice changes documentation/test-plan analysis only and implements no runtime behavior.
## Validation log
- `pnpm exec prettier --check docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md docs/scratchpads/753-kbn010-threat-gate.md` — PASS.
- Changed-doc link validator — PASS (`relative_links=0`, one external issue link); `curl -fsSIL https://git.mosaicstack.dev/mosaicstack/stack/issues/753` — PASS.
- The first inline link-validator invocation had a Python f-string syntax error; corrected once and rerun successfully without changing the deliverable.
- `pnpm format:check` — PASS.
- `pnpm lint` — PASS (23 tasks successful).
- `pnpm typecheck` — PASS (42 tasks successful).
- `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json --noEmit` — PASS.
- Scoped diff review — PASS: authored delta is limited to the exclusive deliverable and this scratchpad; no runtime/schema/config/dependency/CI/deployment file is changed, and `docs/native-kanban-sot/TASKS.md` has no worker worktree delta from tracking commit `9b55de0`.
- Independent SecReview remains pending and cannot return PASS until contract authority resolves `KBN010-SI-001`.
- Final formatting/diff/test-ID completeness review — PASS (all catalog prefixes contiguous with no duplicate IDs).
- Remaining: scoped commit, queue guard, and push.
## 2026-07-14 KBN010-SI-001 contract-authority amendment
- Authority: `web1:mosaic-100` directed the minimal rc.4 amendment under issue #753: add a non-partial unique candidate key on `missions(workspace_id, id)` while retaining global `missions.id` uniqueness and the project-congruent `(workspace_id, project_id, id)` key.
- Rationale: artifacts and approval decisions are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would broaden frozen v1 semantics without improving tenant safety.
- Plan: amend only the frozen schema contract and shared contract, freeze exact DDL ordering and future migration negatives, run scoped/full validation and independent review, then commit and push without opening/merging a PR or closing #753.
- TDD: not applicable because this is a design-contract amendment with no runtime schema or migration implementation; rc.4 freezes future executable empty/prod/N-1/rollback/foreign-workspace and duplicate-key-feasibility tests.
- Budget: no explicit cap supplied; use a 20K-equivalent soft working cap and one bounded worker lane.
- Read-only #757 boundary: PR #757 adds separate logical-agent connector lease/CAS fencing (`logical_agent_connector_leases.lease_epoch`) in runtime schema and connector contracts. SI-001 changes only the frozen mission candidate key; it does not consume, alter, or reinterpret connector fencing, task fencing, lease authority, or #757 ownership.
### Amendment verification evidence
- Frozen schema: added exactly one non-partial `missions_workspace_id_uidx` on `(workspace_id, id)`; retained the global `id` primary key and `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`.
- FK reconciliation: targeted static checks prove the candidate key precedes both `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk`; each continues to reference exact ordered columns `(workspace_id, mission_id)``missions(workspace_id, id)` with RESTRICT deletion.
- Shared contract: candidate version is `1.0.0-rc.4`; authority, rationale, exact effect/non-effect, candidate-before-FK DDL order, duplicate feasibility, empty/prod/N-1/rollback/foreign-workspace future tests, and unchanged KCR/SOT/tenant/proposal/fencing/no-cascade invariants are explicit.
- Changed-file Prettier, contract ESLint, `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json`, and targeted SI-001 static checks — PASS.
- Full `pnpm format:check && pnpm lint && pnpm typecheck` — PASS (23 lint tasks and 42 typecheck/build tasks).
- Independent Codex security review — PASS, zero critical/high/medium/low findings; tenant isolation is preserved.
- Independent Codex code review confirmed the candidate key repairs both FK targets and reported no blocker on the authorized delta. Its initial request to rewrite the historical KBN-010 verdict conflicts with the exclusive scope and was resolved by documenting the immutable-evidence boundary in rc.4; its remaining finding concerns pre-existing live `.mosaic` session files, which are untouched and excluded from this commit.
- Scoped diff: only the two frozen contract files and this append-only scratchpad amendment are staged for delivery; no runtime schema, migration, task plan, gate verdict, provider artifact, or #757-owned file is included.
## 2026-07-14 final rc.4 KBN-010 disposition
- Control-plane direction authorized final disposition edits only to `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and this append-only scratchpad; contract author `web1:kbn-contract` remained idle and undisturbed.
- Exact reviewed contract object: commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`.
- Corroborating review identities: full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`; stable patch-id `058cf98026fcd1043703c866aee047c8bb144740`. A command-rendered patch digest varied by Git rendering command/options and is non-authoritative; commit+tree+exact file content are canonical.
- Independent Homelab non-author schema/security review verdict: **APPROVE**. It confirmed the rc.4 `missions_workspace_id_uidx(workspace_id,id)` repairs both dependent FKs while retaining the global PK and project-congruent key; tenant, polymorphic exactly-one-target, RESTRICT/no-cascade, N-1/rollback semantics remain valid.
- Read-only #757 cross-check: no shared table, index, FK, identity, fence, or authority collision with connector lease/CAS fencing.
- Final KBN-010 gate decision: **PASS / GO** at rc.4 with `UNRESOLVED SCHEMA IMPACTS` equal to exact `none`. Historical SI-001 detection remains in the gate document as evidence that rc.3 was invalid.
- No runtime schema/migration/API/config/dependency/CI/deployment implementation is claimed. KBN-100 must still implement candidate-before-dependent-FK ordering, duplicate feasibility, empty/prod/N-1/rollback evidence, exact FK reconciliation, and separate artifact/approval foreign-workspace negatives (N100-45..50).
- TDD remains not applicable because this continuation changes only documentation/evidence disposition and no runtime behavior.
- Remaining orchestrator-owned sequence: worker validation/commit/push → PR open/update → Ultron review → squash merge → terminal-green post-main CI → close #753 → release KBN-100. KBN-100 is not released earlier.
### Final worker validation
- Changed-doc Prettier and link checks — PASS; issue #753 external link returned successfully.
- Static future-test catalog check — PASS: all prefixes are individually enumerated, contiguous, and duplicate-free; N100 now spans N100-01..50.
- rc.4 disposition assertions — PASS: gate status PASS/GO, frozen target rc.4, exact `none` unresolved section, independent APPROVE, review identities, and N100-50 are present.
- Review identity reproduction — PASS: commit tree `7ebab8fa530a7180036928cea9527f808548aa14`, full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`, and stable patch-id `058cf98026fcd1043703c866aee047c8bb144740` match.
- rc.4 frozen-contract static check — PASS: the candidate key is unique in the declaration and precedes both dependent FKs; frozen contract files remain byte-identical to commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`.
- `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json --noEmit` — PASS.
- `pnpm format:check` — PASS.
- `pnpm lint` — PASS (23 tasks successful).
- `pnpm typecheck` — PASS (42 tasks successful).
- Scoped diff — PASS: only the gate document and this scratchpad are authored changes; contracts, TASKS, requirements, runtime/schema/migration/config/dependency/CI/deployment and #757-owned files are unchanged. Live `.mosaic` session state remains untouched and excluded.
- Remaining worker steps: final formatting/scoped staging, commit `docs(#753): clear KBN-010 schema gate`, queue guard, push, and control-plane notification.

View File

@@ -0,0 +1,57 @@
# Scratchpad — #756 Official Discord channel plugin
- **Task / issue:** Official Discord channel plugin / #756
- **Branch:** `feat/756-official-discord-plugin`
- **Worktree:** `/home/hermes/agent-work/stack-discord-plugin`
- **Base:** `origin/main` at `49e8a54105eddf41e8e0e44603ded616ee76044f`
- **Objective:** Deliver harness-neutral Discord routing, native mention-to-thread behavior, untagged in-channel interaction, fail-closed channel/user authorization, and transport-neutral contracts for future official channel plugins.
- **Collision boundary:** Do not modify orchestrator-to-Pi migration, logical-agent lease/fencing (#754/#755), runtime provider implementations, or orchestrator-owned `docs/TASKS.md`.
- **Working budget:** 55K tokens for requirements, implementation, tests, documentation, independent review, and delivery. No user-specified hard cap. Reduce optional refactoring before reducing acceptance coverage.
## Assumptions
1. **ASSUMPTION:** Every configured Discord channel is intentionally agent-bound, so an authorized human's untagged message is agent input and receives an in-channel response. Rationale: this satisfies the requested no-tag behavior without activating the bot in arbitrary channels.
2. **ASSUMPTION:** Mentioning the bot in a parent channel creates or reuses a public Discord thread; messages already in a thread remain in that thread without repeated mentions. Rationale: Discord does not support nested threads and the request describes tags as the thread-selection signal.
3. **ASSUMPTION:** One bot process may host multiple configured channel-to-logical-agent bindings. Rationale: bindings are already configuration-owned and this avoids per-agent Discord credentials.
4. **ASSUMPTION:** Static guild/channel/user allowlists and paired-user roles remain the administration surface for this slice. Rationale: dynamic admin UI is larger and can be added without changing adapter contracts.
5. **ASSUMPTION:** The stable conversation handle contains logical agent plus Discord channel/thread identity and never a harness/provider identifier. Rationale: runtime re-enrollment and the active lease work can change Claude/Codex/Pi/OpenCode behind the same channel connection.
6. **ASSUMPTION:** Canonical documentation remains in-repository for this slice; no external publishing is performed.
## Plan
1. Update `docs/PRD.md` before code with #756 scope, constraints, assumptions, and acceptance criteria.
2. Add failing Discord behavior and authorization tests first.
3. Add transport-neutral channel DTO/contracts in `@mosaicstack/types`.
4. Implement mention-to-thread, untagged in-channel, existing-thread, stable conversation, and adapter health behavior without runtime-specific imports.
5. Update admin/developer/channel protocol docs and documentation checklist.
6. Run focused tests, typecheck, lint, formatting, full applicable baseline, and coverage.
7. Run independent code and security review; remediate and re-review.
8. Commit, queue-guard, push, open PR to `main`, wait for green CI, squash merge, verify merged CI, and close #756.
## Progress
- 2026-07-14: Loaded mission state (none active), global/project guidance, current `origin/main`, existing Discord/Tess/fleet connector architecture, issue #709 history, and active portability issues #754/#755.
- 2026-07-14: Created issue #756 through the Mosaic wrapper.
- 2026-07-14: Created isolated worktree/branch from current `origin/main`; the stale root checkout and unrelated QA artifacts remain untouched.
## TDD decision
Required and applied. This change modifies authorization-sensitive remote ingress and routing behavior. Failing permission and routing tests will be captured before implementation.
## Risks / blockers
- Active logical-agent lease work may later add stronger fencing fields. This slice must expose a clean, harness-neutral seam without duplicating that schema.
- Discord thread creation is an external side effect. Unit tests use a typed fake; live credential smoke testing is out of scope and must not use committed secrets.
- Repository-wide checks may expose unrelated baseline debt; changed-scope evidence and CI remain mandatory.
## Verification evidence
- `pnpm format:check` — passed.
- `git diff --check` — passed.
- `pnpm typecheck` — passed (42 Turbo tasks).
- `pnpm lint` — passed (23 Turbo tasks).
- `pnpm build` — passed (23 Turbo tasks).
- `pnpm --filter @mosaicstack/discord-plugin test` — passed: 44 tests; V8 coverage 92.18% statements/lines, 86.55% branches, 100% functions (all configured thresholds ≥85%).
- Focused gateway verification passed: Discord ingress/security, cross-surface, ownership, redaction/concurrency, and agent attachment tests.
- `pnpm test` — changed-scope suites passed; repository baseline remains blocked by `apps/gateway/src/__tests__/cross-user-isolation.test.ts` requiring PostgreSQL at localhost port 5433 (`ECONNREFUSED`). The failure is unrelated to this change.
- Independent code/security re-reviews requested after final remediation; reports are stored under ignored `docs/reports/` evidence paths.

View File

@@ -1,3 +1,28 @@
# Tess Plugin Authoring # Tess Plugin Authoring
Plugins are replaceable adapters. Declare capabilities, derive scope from trusted context, preserve correlation IDs, redact before persistence/egress, and return unsupported operations as fail-closed results. Names and identities are configuration data, not literals in keys or defaults. Plugins are replaceable adapters. Declare capabilities, derive scope from trusted context, preserve correlation IDs, redact before persistence/egress, and return unsupported operations as fail-closed results. Names and identities are configuration data, not literals in keys or defaults.
## Official channel adapter contract
Official Discord, Matrix, Slack, and future channel adapters share contracts exported from `@mosaicstack/types` under `channel/`:
- `OfficialChannelAdapter` provides `name`, `start()`, `stop()`, and non-throwing connection `health()`.
- `ChannelMessageDto` and `ChannelAttachmentDto` normalize transport data with JSON-safe metadata.
- `ChannelBindingDto` and `ChannelAuthorizedPrincipalDto` normalize configuration-owned logical-agent binding and the already-allowlisted/paired external actor.
- `ChannelIngressDto` carries operation, correlation, native message ID, authorized principal, normalized message, and stable route into `ChannelIngressPort`.
- `ChannelConversationRouteDto` binds a configured channel to `logicalAgentId`, stable `conversationId`, authorization parent, and response target.
- `ChannelEgressDto` and `ChannelEgressPort` separate where a response is delivered from the gateway's runtime/provider selection.
`ChannelConversationRouteDto` deliberately has no harness, provider, model, process, or native runtime-session field. The gateway owns runtime selection, durable enrollment, authorization, audit, and lease/fencing. A channel adapter must not call Claude, Codex, Pi, OpenCode, tmux, or Matrix runtime providers directly. Discord currently preserves its signed Socket.IO compatibility ingress for established gateway authentication/replay/approval controls while normalizing the same ingress DTO; supplied direct ports are the future registration path.
## Adapter requirements
1. Resolve configuration-owned channel and logical-agent bindings before dispatch. A binding may carry a trusted gateway agent-config reference, but the stable route contains only the logical agent; gateway verifies the reference resolves to that agent before runtime selection.
2. Apply channel-native allowlists and paired-user roles before any external side effect such as thread creation.
3. Preserve native message ID, correlation ID, channel/thread address, attachments, and response target.
4. Treat normal channel parents (for example Discord categories) separately from thread parents.
5. Keep reconnect and conversation identity independent of the active runtime provider.
6. Report sanitized connection/routing failures without message bodies or credentials.
7. Pass the shared route/authorization contract suite plus adapter-specific translation tests.
Discord establishes the first policy: authorized untagged messages respond in the configured channel; a mention creates a thread or reuses the thread already attached to that message; existing thread messages stay there. Runtime control commands remain on the current durable session. Matrix and Slack should translate native rooms/threads into the same route and response-target semantics rather than adding transport branches to gateway core.

View File

@@ -1,5 +1,13 @@
# Tess User Guide # Tess User Guide
## Discord conversations
In a configured Tess/interaction channel, an authorized untagged message is sent to the bound logical agent and its response appears in the channel. Mention the bot when starting a separate topic: Mosaic reuses a thread already attached to that same Discord message, or creates a new thread for the message, and responds there. Continue in that thread without tagging the bot again. Messages from unconfigured channels or users without an authorized pairing are ignored without creating a thread.
`/approve` and `/stop <approval>` operate on the current channel/thread session and do not open a new thread. The Discord connection is bound to the logical agent conversation, not Claude, Codex, Pi, OpenCode, or another harness; a runtime handoff behind Mosaic does not change where you continue the conversation.
## CLI and HTTP interaction
All HTTP interaction calls require authenticated session credentials and `X-Correlation-Id`. Use `GET /api/interaction/{agentName}/sessions?provider=...` to list only visible runtime sessions, then enroll with `POST .../sessions/{sessionId}/enroll` body `{providerId,runtimeSessionId}`. Attach uses `{mode:"read"}`; send uses `{content,idempotencyKey}`. Stop requires `{approvalRef}` and fails with 403 without the exact durable approval. Recovery only requeues interrupted durable work. All HTTP interaction calls require authenticated session credentials and `X-Correlation-Id`. Use `GET /api/interaction/{agentName}/sessions?provider=...` to list only visible runtime sessions, then enroll with `POST .../sessions/{sessionId}/enroll` body `{providerId,runtimeSessionId}`. Attach uses `{mode:"read"}`; send uses `{content,idempotencyKey}`. Stop requires `{approvalRef}` and fails with 403 without the exact durable approval. Recovery only requeues interrupted durable work.
Memory is user-scoped: preferences support list/get/upsert/delete; insights support list/get/create/delete; search body is `{query,limit?,maxDistance?}`. Mos work is handed off with `POST /api/coord/mos/handoff`; observe and result use the returned handoff ID. Memory is user-scoped: preferences support list/get/upsert/delete; insights support list/get/create/delete; search body is `{query,limit?,maxDistance?}`. Mos work is handed off with `POST /api/coord/mos/handoff`; observe and result use the returned handoff ID.

View File

@@ -28,6 +28,7 @@ export default tseslint.config(
'apps/web/e2e/helpers/*.ts', 'apps/web/e2e/helpers/*.ts',
'apps/web/playwright.config.ts', 'apps/web/playwright.config.ts',
'apps/gateway/vitest.config.ts', 'apps/gateway/vitest.config.ts',
'plugins/discord/vitest.config.ts',
'packages/db/vitest.config.ts', 'packages/db/vitest.config.ts',
'packages/storage/vitest.config.ts', 'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts', 'packages/mosaic/vitest.config.ts',

View File

@@ -0,0 +1,242 @@
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
ROSTER_V2_JSON_SCHEMA,
RosterV2ValidationError,
parseRosterV2,
renderRosterV2Yaml,
} from './roster-v2.js';
const validRoster = `
version: 2
generation: 7
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~/src
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: ~/src
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`;
describe('roster v2 structural compiler', (): void => {
it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
const roster = parseRosterV2(validRoster, 'yaml');
expect(roster).toEqual({
version: 2,
generation: 7,
transport: 'tmux',
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
defaults: { workingDirectory: '~/src', runtime: 'pi' },
runtimes: { pi: { resetCommand: '/new' } },
agents: [
{
name: 'coder0',
alias: 'Coder 0',
className: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
toolPolicy: 'code',
workingDirectory: '~/src',
persistentPersona: false,
resetBetweenTasks: true,
lifecycle: { enabled: true, desiredState: 'stopped' },
launch: { yolo: true },
},
],
});
expect(renderRosterV2Yaml(roster)).toBe(validRoster.trimStart());
});
it('parses JSON and produces the same normalized model', (): void => {
const yaml = parseRosterV2(validRoster, 'yaml');
const json = JSON.stringify({
version: 2,
generation: 7,
transport: 'tmux',
tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' },
defaults: { working_directory: '~/src', runtime: 'pi' },
runtimes: { pi: { reset_command: '/new' } },
agents: [
{
name: 'coder0',
alias: 'Coder 0',
class: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
tool_policy: 'code',
working_directory: '~/src',
persistent_persona: false,
reset_between_tasks: true,
lifecycle: { enabled: true, desired_state: 'stopped' },
launch: { yolo: true },
},
],
});
expect(parseRosterV2(json, 'json')).toEqual(yaml);
});
it('sorts runtime and agent maps in the deterministic renderer', (): void => {
const roster = parseRosterV2(
validRoster
.replace(
'runtimes:\n pi:\n reset_command: /new',
'runtimes:\n pi:\n reset_command: /new\n codex:\n reset_command: /clear',
)
.replace(
'agents:\n - name: coder0',
'agents:\n - name: reviewer\n alias: Reviewer\n class: review\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: medium\n tool_policy: review\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0',
),
'yaml',
);
const rendered = renderRosterV2Yaml(roster);
expect(rendered.indexOf(' codex:')).toBeLessThan(rendered.indexOf(' pi:'));
expect(rendered.indexOf(' - name: coder0')).toBeLessThan(
rendered.indexOf(' - name: reviewer'),
);
});
it.each([
['v1 document', validRoster.replace('version: 2', 'version: 1'), /v1.*existing v1 path/i],
[
'unknown connector',
`${validRoster}\nconnector:\n kind: matrix\n`,
/unsupported field.*connector/i,
],
[
'remote host',
validRoster.replace(
'runtime: pi\n provider',
'runtime: pi\n host: remote\n provider',
),
/unsupported field.*host/i,
],
[
'secret reference',
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n secret_ref: vault://x'),
/unsupported field.*secret_ref/i,
],
[
'channel override',
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n channels: discord'),
/unsupported field.*channels/i,
],
[
'arbitrary command',
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n command: whoami'),
/unsupported field.*command/i,
],
[
'gateway field',
`${validRoster}\ngateway:\n url: https://gateway.example\n`,
/unsupported field.*gateway/i,
],
[
'missing required field',
validRoster.replace(' model: gpt-5.6-sol\n', ''),
/model.*required/i,
],
[
'invalid type',
validRoster.replace('generation: 7', 'generation: seven'),
/generation.*integer/i,
],
[
'unsafe generation',
validRoster.replace('generation: 7', 'generation: 9007199254740992'),
/generation.*integer/i,
],
[
'duplicate names',
validRoster.replace(
' - name: coder0',
' - name: coder0\n alias: Duplicate\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0',
),
/duplicate agent name/i,
],
['invalid name', validRoster.replace('name: coder0', 'name: ../coder0'), /invalid agent name/i],
[
'invalid transport',
validRoster.replace('transport: tmux', 'transport: matrix'),
/transport.*tmux/i,
],
[
'invalid runtime',
validRoster.replace('runtime: pi', 'runtime: matrix'),
/runtime.*supported/i,
],
[
'invalid reasoning',
validRoster.replace('reasoning: high', 'reasoning: extreme'),
/reasoning.*low.*medium.*high/i,
],
[
'ambiguous socket',
validRoster.replace('socket_name: mosaic-fleet', 'socket_name: default/socket'),
/socket_name/i,
],
[
'agent socket override',
validRoster.replace(
'runtime: pi\n provider',
'runtime: pi\n socket: another\n provider',
),
/unsupported field.*socket/i,
],
])('rejects %s', (_name: string, source: string, expected: RegExp): void => {
expect((): void => {
parseRosterV2(source, 'yaml');
}).toThrow(expected);
});
it('rejects malformed JSON as a validation error', (): void => {
expect((): void => {
parseRosterV2('{', 'json');
}).toThrow(RosterV2ValidationError);
});
it('declares supported runtime map keys in the executable schema', (): void => {
expect(ROSTER_V2_JSON_SCHEMA).toMatchObject({
properties: {
runtimes: { propertyNames: { enum: ['claude', 'codex', 'opencode', 'pi'] } },
},
});
});
it('keeps the checked-in documentation schema structurally identical to the executable schema', async (): Promise<void> => {
const schemaPath = fileURLToPath(
new URL('../../../../docs/fleet/reference/roster-v2.schema.json', import.meta.url),
);
const documented = await readFile(schemaPath, 'utf8');
expect(JSON.parse(documented) as unknown).toEqual(ROSTER_V2_JSON_SCHEMA);
});
});

View File

@@ -0,0 +1,473 @@
import YAML from 'yaml';
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
export const ROSTER_V2_DESIRED_STATES = ['running', 'stopped'] as const;
export type RosterV2RuntimeName = (typeof ROSTER_V2_SUPPORTED_RUNTIMES)[number];
export type RosterV2ReasoningLevel = (typeof ROSTER_V2_REASONING_LEVELS)[number];
export type RosterV2DesiredState = (typeof ROSTER_V2_DESIRED_STATES)[number];
export type RosterV2InputFormat = 'json' | 'yaml';
export interface FleetRosterV2Tmux {
readonly socketName: string;
readonly holderSession: string;
}
export interface FleetRosterV2Defaults {
readonly workingDirectory: string;
readonly runtime: RosterV2RuntimeName;
}
export interface FleetRosterV2Runtime {
readonly resetCommand: string;
}
export interface FleetRosterV2Lifecycle {
readonly enabled: boolean;
readonly desiredState: RosterV2DesiredState;
}
export interface FleetRosterV2Launch {
readonly yolo: boolean;
}
export interface FleetRosterV2Agent {
readonly name: string;
readonly alias: string;
readonly className: string;
readonly runtime: RosterV2RuntimeName;
readonly provider: string;
readonly model: string;
readonly reasoning: RosterV2ReasoningLevel;
readonly toolPolicy: string;
readonly workingDirectory: string;
readonly persistentPersona: boolean;
readonly resetBetweenTasks: boolean;
readonly lifecycle: FleetRosterV2Lifecycle;
readonly launch: FleetRosterV2Launch;
}
export interface FleetRosterV2 {
readonly version: 2;
readonly generation: number;
readonly transport: 'tmux';
readonly tmux: FleetRosterV2Tmux;
readonly defaults: FleetRosterV2Defaults;
readonly runtimes: Readonly<Record<string, FleetRosterV2Runtime>>;
readonly agents: readonly FleetRosterV2Agent[];
}
export class RosterV2ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'RosterV2ValidationError';
}
}
type JsonSchema = string | number | boolean | null | JsonSchema[] | { [key: string]: JsonSchema };
/**
* Executable v2 structural contract. The checked-in documentation schema is
* structurally compared to this value in roster-v2.spec.ts.
*/
export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json',
title: 'Mosaic local tmux fleet roster v2',
type: 'object',
additionalProperties: false,
required: ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'],
properties: {
version: { const: 2 },
generation: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
transport: { const: 'tmux' },
tmux: {
type: 'object',
additionalProperties: false,
required: ['socket_name', 'holder_session'],
properties: {
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
},
},
defaults: {
type: 'object',
additionalProperties: false,
required: ['working_directory', 'runtime'],
properties: {
working_directory: { type: 'string', minLength: 1 },
runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
},
},
runtimes: {
type: 'object',
minProperties: 1,
propertyNames: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
additionalProperties: {
type: 'object',
additionalProperties: false,
required: ['reset_command'],
properties: { reset_command: { type: 'string', minLength: 1 } },
},
},
agents: {
type: 'array',
minItems: 1,
items: {
type: 'object',
additionalProperties: false,
required: [
'name',
'alias',
'class',
'runtime',
'provider',
'model',
'reasoning',
'tool_policy',
'working_directory',
'persistent_persona',
'reset_between_tasks',
'lifecycle',
'launch',
],
properties: {
name: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]*$' },
alias: { type: 'string', minLength: 1 },
class: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
provider: { type: 'string', minLength: 1 },
model: { type: 'string', minLength: 1 },
reasoning: { enum: [...ROSTER_V2_REASONING_LEVELS] },
tool_policy: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
working_directory: { type: 'string', minLength: 1 },
persistent_persona: { type: 'boolean' },
reset_between_tasks: { type: 'boolean' },
lifecycle: {
type: 'object',
additionalProperties: false,
required: ['enabled', 'desired_state'],
properties: {
enabled: { type: 'boolean' },
desired_state: { enum: [...ROSTER_V2_DESIRED_STATES] },
},
},
launch: {
type: 'object',
additionalProperties: false,
required: ['yolo'],
properties: { yolo: { type: 'boolean' } },
},
},
},
},
},
};
const ROOT_KEYS = ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'];
const TMUX_KEYS = ['socket_name', 'holder_session'];
const DEFAULT_KEYS = ['working_directory', 'runtime'];
const RUNTIME_KEYS = ['reset_command'];
const AGENT_KEYS = [
'name',
'alias',
'class',
'runtime',
'provider',
'model',
'reasoning',
'tool_policy',
'working_directory',
'persistent_persona',
'reset_between_tasks',
'lifecycle',
'launch',
];
const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
const LAUNCH_KEYS = ['yolo'];
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
/** Parses YAML or JSON and compiles only the local-tmux roster v2 contract. */
export function parseRosterV2(source: string, format?: RosterV2InputFormat): FleetRosterV2 {
const parsed = parseSource(source, format);
return normalizeRosterV2(parsed);
}
/** Renders canonical snake_case YAML with sorted runtime and agent entries. */
export function renderRosterV2Yaml(roster: FleetRosterV2): string {
const normalized = normalizeRosterV2(toSourceShape(roster));
return YAML.stringify(toSourceShape(normalized));
}
function parseSource(source: string, format?: RosterV2InputFormat): unknown {
const resolvedFormat = format ?? (source.trimStart().startsWith('{') ? 'json' : 'yaml');
try {
if (resolvedFormat === 'json') return JSON.parse(source) as unknown;
return YAML.parse(source) as unknown;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new RosterV2ValidationError(`Roster v2 ${resolvedFormat} parse failed: ${detail}`);
}
}
export function normalizeRosterV2(raw: unknown): FleetRosterV2 {
const root = requiredObject(raw, 'Roster v2');
assertKnownKeys(root, 'Roster v2', ROOT_KEYS);
if (root.version === 1) {
throw new RosterV2ValidationError(
'Roster v2 compiler rejects v1 input; use the existing v1 path until migration.',
);
}
if (root.version !== 2) throw new RosterV2ValidationError('Roster v2 version must be 2.');
const generation = requiredPositiveInteger(root.generation, 'Roster v2 generation');
const transport = requiredEnum(root.transport, 'Roster v2 transport', ['tmux'] as const);
const tmux = normalizeTmux(root.tmux);
const defaults = normalizeDefaults(root.defaults);
const runtimes = normalizeRuntimes(root.runtimes);
const agents = normalizeAgents(root.agents, runtimes);
if (!runtimes[defaults.runtime]) {
throw new RosterV2ValidationError(
`Roster v2 defaults runtime "${defaults.runtime}" must be declared in runtimes.`,
);
}
return { version: 2, generation, transport, tmux, defaults, runtimes, agents };
}
function normalizeTmux(value: unknown): FleetRosterV2Tmux {
const raw = requiredObject(value, 'Roster v2 tmux');
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
return {
socketName: requiredTmuxIdentifier(raw.socket_name, 'Roster v2 tmux socket_name'),
holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'),
};
}
function normalizeDefaults(value: unknown): FleetRosterV2Defaults {
const raw = requiredObject(value, 'Roster v2 defaults');
assertKnownKeys(raw, 'Roster v2 defaults', DEFAULT_KEYS);
return {
workingDirectory: requiredString(raw.working_directory, 'Roster v2 defaults working_directory'),
runtime: requiredRuntime(raw.runtime, 'Roster v2 defaults runtime'),
};
}
function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV2Runtime>> {
const raw = requiredObject(value, 'Roster v2 runtimes');
const names = Object.keys(raw);
if (names.length === 0)
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
const result: Record<string, FleetRosterV2Runtime> = {};
for (const name of names.sort()) {
const runtime = requiredRuntime(name, 'Roster v2 runtime name');
const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
result[runtime] = {
resetCommand: requiredString(
config.reset_command,
`Roster v2 runtime "${runtime}" reset_command`,
),
};
}
return result;
}
function normalizeAgents(
value: unknown,
runtimes: Readonly<Record<string, FleetRosterV2Runtime>>,
): readonly FleetRosterV2Agent[] {
if (!Array.isArray(value) || value.length === 0) {
throw new RosterV2ValidationError('Roster v2 agents must be a non-empty array.');
}
const seen = new Set<string>();
const agents = value.map((candidate: unknown, index: number): FleetRosterV2Agent => {
const raw = requiredObject(candidate, `Roster v2 agents[${index}]`);
assertKnownKeys(raw, `Roster v2 agents[${index}]`, AGENT_KEYS);
const name = requiredIdentifier(raw.name, `Roster v2 agents[${index}] name`);
if (seen.has(name))
throw new RosterV2ValidationError(`Roster v2 has duplicate agent name: ${name}.`);
seen.add(name);
const runtime = requiredRuntime(raw.runtime, `Roster v2 agent "${name}" runtime`);
if (!runtimes[runtime]) {
throw new RosterV2ValidationError(
`Roster v2 agent "${name}" runtime "${runtime}" must be declared in runtimes.`,
);
}
return {
name,
alias: requiredString(raw.alias, `Roster v2 agent "${name}" alias`),
className: requiredPolicyIdentifier(raw.class, `Roster v2 agent "${name}" class`),
runtime,
provider: requiredString(raw.provider, `Roster v2 agent "${name}" provider`),
model: requiredString(raw.model, `Roster v2 agent "${name}" model`),
reasoning: requiredEnum(
raw.reasoning,
`Roster v2 agent "${name}" reasoning`,
ROSTER_V2_REASONING_LEVELS,
),
toolPolicy: requiredPolicyIdentifier(
raw.tool_policy,
`Roster v2 agent "${name}" tool_policy`,
),
workingDirectory: requiredString(
raw.working_directory,
`Roster v2 agent "${name}" working_directory`,
),
persistentPersona: requiredBoolean(
raw.persistent_persona,
`Roster v2 agent "${name}" persistent_persona`,
),
resetBetweenTasks: requiredBoolean(
raw.reset_between_tasks,
`Roster v2 agent "${name}" reset_between_tasks`,
),
lifecycle: normalizeLifecycle(raw.lifecycle, name),
launch: normalizeLaunch(raw.launch, name),
};
});
return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
left.name.localeCompare(right.name),
);
}
function normalizeLifecycle(value: unknown, agentName: string): FleetRosterV2Lifecycle {
const raw = requiredObject(value, `Roster v2 agent "${agentName}" lifecycle`);
assertKnownKeys(raw, `Roster v2 agent "${agentName}" lifecycle`, LIFECYCLE_KEYS);
return {
enabled: requiredBoolean(raw.enabled, `Roster v2 agent "${agentName}" lifecycle enabled`),
desiredState: requiredEnum(
raw.desired_state,
`Roster v2 agent "${agentName}" lifecycle desired_state`,
ROSTER_V2_DESIRED_STATES,
),
};
}
function normalizeLaunch(value: unknown, agentName: string): FleetRosterV2Launch {
const raw = requiredObject(value, `Roster v2 agent "${agentName}" launch`);
assertKnownKeys(raw, `Roster v2 agent "${agentName}" launch`, LAUNCH_KEYS);
return { yolo: requiredBoolean(raw.yolo, `Roster v2 agent "${agentName}" launch yolo`) };
}
function requiredObject(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new RosterV2ValidationError(`${label} must be an object.`);
}
return value as Record<string, unknown>;
}
function assertKnownKeys(
value: Record<string, unknown>,
label: string,
allowedKeys: readonly string[],
): void {
const allowed = new Set(allowedKeys);
const unknown = Object.keys(value).filter((key: string): boolean => !allowed.has(key));
if (unknown.length > 0) {
throw new RosterV2ValidationError(`${label} has unsupported field(s): ${unknown.join(', ')}.`);
}
}
function requiredString(value: unknown, label: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new RosterV2ValidationError(`${label} is required and must be a non-empty string.`);
}
return value.trim();
}
function requiredIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label);
if (!IDENTIFIER.test(result)) {
throw new RosterV2ValidationError(`Invalid agent name (${label}): ${result}.`);
}
return result;
}
function requiredTmuxIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label);
if (!TMUX_IDENTIFIER.test(result))
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
return result;
}
function requiredPolicyIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label);
if (!POLICY_IDENTIFIER.test(result))
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
return result;
}
function requiredPositiveInteger(value: unknown, label: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new RosterV2ValidationError(`${label} must be a positive integer.`);
}
return value;
}
function requiredBoolean(value: unknown, label: string): boolean {
if (typeof value !== 'boolean') throw new RosterV2ValidationError(`${label} must be a boolean.`);
return value;
}
function requiredRuntime(value: unknown, label: string): RosterV2RuntimeName {
return requiredEnum(value, label, ROSTER_V2_SUPPORTED_RUNTIMES);
}
function requiredEnum<T extends string>(value: unknown, label: string, allowed: readonly T[]): T {
if (typeof value !== 'string' || !allowed.includes(value as T)) {
throw new RosterV2ValidationError(
`${label} must be one of the supported values: ${allowed.join(', ')}.`,
);
}
return value as T;
}
function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
return {
version: roster.version,
generation: roster.generation,
transport: roster.transport,
tmux: { socket_name: roster.tmux.socketName, holder_session: roster.tmux.holderSession },
defaults: {
working_directory: roster.defaults.workingDirectory,
runtime: roster.defaults.runtime,
},
runtimes: Object.fromEntries(
Object.entries(roster.runtimes)
.sort(([left], [right]): number => left.localeCompare(right))
.map(([name, runtime]): [string, unknown] => [
name,
{ reset_command: runtime.resetCommand },
]),
),
agents: [...roster.agents]
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
left.name.localeCompare(right.name),
)
.map(
(agent: FleetRosterV2Agent): Record<string, unknown> => ({
name: agent.name,
alias: agent.alias,
class: agent.className,
runtime: agent.runtime,
provider: agent.provider,
model: agent.model,
reasoning: agent.reasoning,
tool_policy: agent.toolPolicy,
working_directory: agent.workingDirectory,
persistent_persona: agent.persistentPersona,
reset_between_tasks: agent.resetBetweenTasks,
lifecycle: {
enabled: agent.lifecycle.enabled,
desired_state: agent.lifecycle.desiredState,
},
launch: { yolo: agent.launch.yolo },
}),
),
};
}

View File

@@ -0,0 +1,43 @@
import type {
ChannelAdapterHealthDto,
ChannelEgressDto,
ChannelIngressDto,
} from './channel.dto.js';
export type ChannelDeliveryErrorCode =
| 'invalid_route'
| 'destination_unavailable'
| 'delivery_failed';
/** Terminal adapter delivery failure surfaced to the gateway/caller. */
export class ChannelDeliveryError extends Error {
readonly name = 'ChannelDeliveryError';
constructor(
readonly code: ChannelDeliveryErrorCode,
message: string,
readonly retryable = false,
options?: ErrorOptions,
) {
super(message, options);
}
}
/** Gateway policy boundary consumed by official channel adapters. */
export interface ChannelIngressPort {
receive(ingress: ChannelIngressDto): Promise<void>;
}
/** Adapter egress boundary used by the gateway after agent output is ready. */
export interface ChannelEgressPort {
send(egress: ChannelEgressDto): Promise<void>;
}
/** Shared lifecycle seam implemented by every official channel adapter. */
export interface OfficialChannelAdapter {
readonly name: string;
start(): Promise<void>;
stop(): Promise<void>;
/** Health is best-effort and never throws for ordinary disconnected state. */
health(): Promise<ChannelAdapterHealthDto>;
}

View File

@@ -0,0 +1,97 @@
/** JSON-safe metadata carried across channel adapter boundaries. */
export type ChannelMetadataValue =
| string
| number
| boolean
| null
| readonly ChannelMetadataValue[]
| { readonly [key: string]: ChannelMetadataValue };
export type ChannelSenderKind = 'user' | 'agent' | 'system';
export type ChannelContentKind = 'text' | 'markdown' | 'code' | 'image' | 'file';
export type ChannelAdapterStatus = 'connected' | 'degraded' | 'disconnected';
export type ChannelAuthorizationRole = 'viewer' | 'operator' | 'admin';
export type ChannelOperation = 'message.send' | 'approval.create' | 'session.stop';
export interface ChannelAttachmentDto {
id: string;
name: string;
mimeType: string | null;
url: string;
sizeBytes?: number;
}
/** Canonical transport-neutral message shape for official channel adapters. */
export interface ChannelMessageDto {
id: string;
channelName: string;
channelId: string;
senderId: string;
senderKind: ChannelSenderKind;
content: string;
contentKind: ChannelContentKind;
timestamp: string;
threadId?: string;
replyToId?: string;
attachments?: readonly ChannelAttachmentDto[];
metadata: Readonly<Record<string, ChannelMetadataValue>>;
}
/** Where an adapter must deliver a response for one normalized conversation turn. */
/** Provisioned external identity after adapter allowlist/pairing checks pass. */
export interface ChannelAuthorizedPrincipalDto {
channelUserId: string;
role: ChannelAuthorizationRole;
/** Needed when gateway policy must authorize a privileged Mosaic operation. */
mosaicUserId?: string;
}
/** Configuration-owned binding. Credentials are intentionally absent. */
export interface ChannelBindingDto {
bindingId: string;
channelName: string;
workspaceId: string;
channelId: string;
logicalAgentId: string;
principals: Readonly<Record<string, ChannelAuthorizedPrincipalDto>>;
}
export interface ChannelResponseTargetDto {
channelId: string;
threadId?: string;
}
/**
* Stable channel-to-session route. Runtime provider, harness, model, process,
* and native runtime session identifiers are intentionally absent.
*/
export interface ChannelConversationRouteDto {
bindingId: string;
logicalAgentId: string;
conversationId: string;
channelName: string;
authorizationChannelId: string;
responseTarget: ChannelResponseTargetDto;
}
/** Authorized adapter-to-gateway ingress after native translation. */
export interface ChannelIngressDto {
correlationId: string;
nativeMessageId: string;
operation: ChannelOperation;
principal: ChannelAuthorizedPrincipalDto;
message: ChannelMessageDto;
route: ChannelConversationRouteDto;
}
/** Gateway-to-adapter egress; runtime/provider identity remains gateway-internal. */
export interface ChannelEgressDto {
correlationId: string;
message: ChannelMessageDto;
route: ChannelConversationRouteDto;
}
export interface ChannelAdapterHealthDto {
status: ChannelAdapterStatus;
detail?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './channel-adapter.js';
export * from './channel.dto.js';

View File

@@ -1,3 +1,4 @@
import type { ChannelAttachmentDto } from '../channel/index.js';
import type { import type {
CommandManifestPayload, CommandManifestPayload,
SlashCommandApprovalResultPayload, SlashCommandApprovalResultPayload,
@@ -73,6 +74,7 @@ export interface ChatMessagePayload {
provider?: string; provider?: string;
modelId?: string; modelId?: string;
agentId?: string; agentId?: string;
attachments?: readonly ChannelAttachmentDto[];
} }
/** Routing decision summary included in session:info for transparency */ /** Routing decision summary included in session:info for transparency */

View File

@@ -1,5 +1,6 @@
export const VERSION = '0.0.0'; export const VERSION = '0.0.0';
export * from './channel/index.js';
export * from './chat/index.js'; export * from './chat/index.js';
export * from './agent/index.js'; export * from './agent/index.js';
export * from './provider/index.js'; export * from './provider/index.js';

67
plugins/discord/README.md Normal file
View File

@@ -0,0 +1,67 @@
# @mosaicstack/discord-plugin
Official Discord channel adapter for Mosaic Stack.
## Behavior
- Runs independently of the bound agent's Claude, Codex, Pi, OpenCode, or future harness.
- Routes authorized untagged messages in configured agent channels and replies in-channel.
- Creates a public Discord thread when the bot is mentioned in a parent channel, or reuses the thread already attached to that same message.
- Keeps follow-ups in existing threads without requiring repeated mentions.
- Keeps `/approve` and `/stop <approval>` on the current durable session.
- Applies guild, parent-channel, user, pairing, role, and per-minute abuse limits before thread creation or gateway dispatch.
- Authenticates to the gateway and signs ingress envelopes with the injected service token.
## Required configuration
| Variable | Purpose |
| --------------------------------------- | -------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot credential |
| `DISCORD_SERVICE_TOKEN` | High-entropy plugin-to-gateway credential |
| `DISCORD_SERVICE_USER_ID` | Provisioned Mosaic service principal |
| `DISCORD_ALLOWED_GUILD_IDS` | Comma-separated guild allowlist |
| `DISCORD_ALLOWED_CHANNEL_IDS` | Comma-separated parent-channel allowlist |
| `DISCORD_ALLOWED_USER_IDS` | Comma-separated Discord user allowlist |
| `DISCORD_INTERACTION_BINDINGS` | JSON channel→logical-agent bindings and paired-user roles |
| `DISCORD_GATEWAY_URL` | Gateway base URL; defaults to the gateway's local development URL |
| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Authorized turns per guild/channel/user per minute; default `30` |
| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Mention-thread routes per guild/channel/user per minute; default `5` |
Supply credentials through the approved runtime secret mechanism. Never commit tokens or binding data containing secrets.
Example binding shape (identifiers are placeholders):
```json
[
{
"instanceId": "interaction-agent",
"agentConfigId": "agent-config-id",
"guildId": "guild-id",
"channelId": "channel-id",
"pairedUsers": {
"discord-user-id": {
"role": "operator",
"mosaicUserId": "mosaic-user-id"
}
}
}
]
```
Each binding's trusted `agentConfigId` must identify a provisioned database agent configuration whose name exactly matches its `instanceId`. Roles are `viewer`, `operator`, and `admin`. `viewer` cannot send agent turns. Runtime approval and stop operations require an `admin` pairing with a provisioned `mosaicUserId`.
The bot needs Discord permissions to view/send messages in configured channels and create/send in public threads. A channel category is not an authorization boundary; threads inherit authorization only from their configured parent text channel.
## Shared contract
The adapter implements `OfficialChannelAdapter` from `@mosaicstack/types`. `ChannelConversationRouteDto` carries only stable logical-agent/channel identity and a response target. Gateway durable-session and provider layers own runtime selection and handoff; Discord code must not import a harness SDK.
## Development
```bash
pnpm --filter @mosaicstack/types build
pnpm --filter @mosaicstack/discord-plugin typecheck
pnpm --filter @mosaicstack/discord-plugin lint
pnpm --filter @mosaicstack/discord-plugin test
pnpm --filter @mosaicstack/discord-plugin build
```

View File

@@ -19,13 +19,16 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests" "test": "vitest run --coverage",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/types": "workspace:^",
"discord.js": "^14.16.0", "discord.js": "^14.16.0",
"socket.io-client": "^4.8.0" "socket.io-client": "^4.8.0"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"tsx": "^4.0.0", "tsx": "^4.0.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^2.0.0" "vitest": "^2.0.0"

View File

@@ -0,0 +1,950 @@
import { EventEmitter } from 'node:events';
import type {
ChannelConversationRouteDto,
ChannelEgressDto,
ChannelIngressDto,
ChannelIngressPort,
} from '@mosaicstack/types';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createDiscordIngressEnvelope,
DiscordPlugin,
parseDiscordInteractionBindings,
resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding,
verifyDiscordIngressEnvelope,
type DiscordIngressEnvelope,
type DiscordInteractionRole,
type DiscordPluginConfig,
} from './index.js';
const SERVICE_TOKEN = 'test-service-token';
interface FakeDiscordMessageOptions {
id?: string;
guildId?: string;
content: string;
mentioned?: boolean;
userId?: string;
channelId?: string;
parentChannelId?: string | null;
isThread?: boolean;
hasThread?: boolean;
existingThreadId?: string;
fetchedThreadId?: string;
createdThreadId?: string;
attachments?: Map<string, FakeDiscordAttachment>;
}
interface FakeDiscordAttachment {
id: string;
name: string;
url: string;
contentType: string | null;
size?: number;
}
interface FakeDiscordMessage {
id: string;
guildId: string;
channelId: string;
author: { id: string; bot: boolean };
mentions: { has(user: { id: string }): boolean };
content: string;
createdAt: Date;
channel: {
parentId: string | null;
isThread(): boolean;
threads?: { fetch(id: string): Promise<{ id: string }> };
};
attachments: Map<string, FakeDiscordAttachment>;
hasThread: boolean;
thread: { id: string } | null;
startThread: ReturnType<typeof vi.fn>;
}
interface FakeGatewaySocket extends EventEmitter {
connected: boolean;
disconnect?: ReturnType<typeof vi.fn>;
}
interface FakeLifecycleClient extends EventEmitter {
user: { id: string; tag: string };
login: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
isReady(): boolean;
guilds: { cache: Map<string, object> };
channels: { cache: Map<string, object> };
}
interface DiscordPluginInternals {
client: {
user: { id: string };
isReady(): boolean;
channels?: {
cache: { get(id: string): { send(options: unknown): Promise<void> } | undefined };
};
};
socket: {
connected: boolean;
emit: ReturnType<typeof vi.fn>;
} | null;
conversationRoutes: Map<string, ChannelConversationRouteDto>;
handleDiscordMessage(message: FakeDiscordMessage): void | Promise<void>;
sendToDiscord(conversationId: string, text: string): Promise<void>;
}
function lifecyclePlugin(): {
plugin: DiscordPlugin;
socket: FakeGatewaySocket;
client: FakeLifecycleClient;
} {
const socket = Object.assign(new EventEmitter(), {
connected: false,
disconnect: vi.fn(),
});
const client = Object.assign(new EventEmitter(), {
user: { id: 'bot-001', tag: 'mosaic-bot' },
login: vi.fn().mockResolvedValue('token'),
destroy: vi.fn().mockResolvedValue(undefined),
isReady: (): boolean => true,
guilds: { cache: new Map<string, object>() },
channels: { cache: new Map<string, object>() },
});
const plugin = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://gateway.invalid',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-user-001' },
},
},
],
},
{
client: client as never,
socketFactory: vi.fn().mockReturnValue(socket) as never,
},
);
return { plugin, socket, client };
}
function createPlugin(
role: DiscordInteractionRole = 'operator',
paired = true,
ingressPort?: ChannelIngressPort,
configOverrides: Partial<DiscordPluginConfig> = {},
): {
plugin: DiscordPlugin;
internals: DiscordPluginInternals;
emit: ReturnType<typeof vi.fn>;
} {
const plugin = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: paired
? {
'user-001': { role, mosaicUserId: 'mosaic-user-001' },
}
: {},
},
],
...configOverrides,
},
{ ingressPort },
);
const emit = vi.fn();
const internals = plugin as unknown as DiscordPluginInternals;
internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true };
internals.socket = { connected: true, emit };
return { plugin, internals, emit };
}
function fakeMessage(options: FakeDiscordMessageOptions): FakeDiscordMessage {
const channelId = options.channelId ?? 'channel-001';
const parentChannelId = options.parentChannelId;
const existingThreadId = options.existingThreadId;
return {
id: options.id ?? 'message-001',
guildId: options.guildId ?? 'guild-001',
channelId,
author: { id: options.userId ?? 'user-001', bot: false },
mentions: { has: (): boolean => options.mentioned ?? false },
content: options.content,
createdAt: new Date('2026-07-14T12:00:00.000Z'),
channel: {
parentId: parentChannelId ?? null,
isThread: (): boolean =>
options.isThread ?? (parentChannelId !== undefined && parentChannelId !== null),
...(options.fetchedThreadId
? {
threads: {
fetch: vi.fn().mockResolvedValue({ id: options.fetchedThreadId }),
},
}
: {}),
},
attachments: options.attachments ?? new Map<string, FakeDiscordAttachment>(),
hasThread: options.hasThread ?? existingThreadId !== undefined,
thread: existingThreadId ? { id: existingThreadId } : null,
startThread: vi.fn().mockResolvedValue({ id: options.createdThreadId ?? 'thread-created-001' }),
};
}
function emittedEnvelope(emit: ReturnType<typeof vi.fn>): DiscordIngressEnvelope {
const call = emit.mock.calls[0] as [string, DiscordIngressEnvelope] | undefined;
expect(call?.[0]).toBe('message');
expect(call?.[1]).toBeDefined();
return (
call?.[1] ??
createDiscordIngressEnvelope(
{
correlationId: 'unreachable',
messageId: 'unreachable',
guildId: 'unreachable',
channelId: 'unreachable',
userId: 'unreachable',
conversationId: 'unreachable',
content: 'unreachable',
},
SERVICE_TOKEN,
)
);
}
afterEach((): void => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('Discord binding configuration', () => {
it('parses role-only and Mosaic-linked pairings', () => {
const bindings = parseDiscordInteractionBindings(
JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'legacy-user': 'operator',
'linked-user': { role: 'admin', mosaicUserId: 'mosaic-admin-001' },
'linked-without-id': { role: 'operator' },
},
},
]),
);
expect(bindings).toHaveLength(1);
expect(resolveDiscordInteractionActorId(bindings[0]!, 'legacy-user')).toBeNull();
expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-user')).toBe('mosaic-admin-001');
expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-without-id')).toBeNull();
expect(resolveDiscordInteractionActorId(bindings[0]!, 'missing-user')).toBeNull();
expect(
resolveDiscordInteractionBinding(bindings, 'guild-001', 'channel-001', 'linked-user', 'bind'),
).toBe(bindings[0]);
expect(
resolveDiscordInteractionBinding(
bindings,
'guild-001',
'channel-001',
'linked-without-id',
'attach',
),
).toBe(bindings[0]);
expect(
resolveDiscordInteractionBinding(
bindings,
'other-guild',
'channel-001',
'linked-user',
'send',
),
).toBeNull();
});
it.each([
['missing value', undefined],
['empty array', '[]'],
['non-object binding', '[null]'],
['missing binding fields', '[{"instanceId":"Nova"}]'],
[
'empty Discord user ID',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"":"operator"}}]',
],
[
'invalid role-only pairing',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":"owner"}}]',
],
[
'non-object pairing',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":42}}]',
],
[
'invalid linked pairing',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":{"role":"admin","mosaicUserId":" "}}}]',
],
])('rejects %s', (_case: string, value: string | undefined) => {
expect(() => parseDiscordInteractionBindings(value)).toThrow();
});
});
describe('Discord ingress integrity', () => {
it.each([
['guild', { guildIds: ['other'], channelIds: ['channel-001'], userIds: ['user-001'] }],
['channel', { guildIds: ['guild-001'], channelIds: ['other'], userIds: ['user-001'] }],
['user', { guildIds: ['guild-001'], channelIds: ['channel-001'], userIds: ['other'] }],
])('rejects an unallowlisted %s', (_field: string, allowlists) => {
const payload = {
correlationId: 'correlation-001',
messageId: 'message-001',
guildId: 'guild-001',
channelId: 'channel-001',
userId: 'user-001',
conversationId: 'Nova:discord:channel-001',
content: 'hello',
};
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, allowlists)).toBeNull();
});
});
describe('Discord adapter lifecycle', () => {
it('starts while the in-process gateway is still connecting, then reports connected', async () => {
const { plugin, socket, client } = lifecyclePlugin();
await plugin.start();
expect(client.login).toHaveBeenCalledWith('unused');
expect(await plugin.health()).toEqual({ status: 'degraded' });
socket.connected = true;
socket.emit('connect');
expect(await plugin.health()).toEqual({ status: 'connected' });
await plugin.stop();
expect(socket.disconnect).toHaveBeenCalledOnce();
expect(client.destroy).toHaveBeenCalledOnce();
});
it('keeps running while Socket.IO reconnects after an initial gateway error', async () => {
const { plugin, socket } = lifecyclePlugin();
const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined);
await plugin.start();
socket.emit('connect_error', new Error('gateway not listening yet'));
expect(error).toHaveBeenCalledWith(
'[discord] Gateway connection error: gateway not listening yet',
);
expect(await plugin.health()).toEqual({ status: 'degraded' });
});
it('cleans up when Discord login fails', async () => {
const { plugin, socket, client } = lifecyclePlugin();
client.login.mockRejectedValueOnce(new Error('Discord authentication rejected'));
await expect(plugin.start()).rejects.toThrow('Discord authentication rejected');
expect(socket.disconnect).toHaveBeenCalledOnce();
expect(client.destroy).toHaveBeenCalledOnce();
});
});
describe('Discord project channel provisioning', () => {
it('returns null without a configured guild or visible guild', async () => {
const { plugin } = createPlugin();
await expect(
plugin.createProjectChannel({ id: 'project-1', name: 'Alpha' }),
).resolves.toBeNull();
const { client } = lifecyclePlugin();
const configured = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
guildId: 'missing-guild',
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [],
},
{ client: client as never },
);
await expect(
configured.createProjectChannel({ id: 'project-1', name: 'Alpha' }),
).resolves.toBeNull();
});
it('creates a normalized Discord project channel', async () => {
const create = vi.fn().mockResolvedValue({ id: 'created-channel-001' });
const { client } = lifecyclePlugin();
client.guilds.cache.set('guild-001', { channels: { create } });
const plugin = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
guildId: 'guild-001',
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [],
},
{ client: client as never },
);
await expect(
plugin.createProjectChannel({
id: 'project-1',
name: ' Project Alpha! ',
description: 'Alpha workspace',
}),
).resolves.toEqual({ channelId: 'created-channel-001' });
expect(create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'mosaic-project-alpha', topic: 'Alpha workspace' }),
);
await plugin.createProjectChannel({ id: 'project-2', name: 'Beta' });
expect(create).toHaveBeenLastCalledWith(
expect.objectContaining({ topic: 'Mosaic project: Beta' }),
);
});
});
function egressFor(
route: ChannelConversationRouteDto,
content = 'agent response',
): ChannelEgressDto {
return {
correlationId: 'egress-correlation-001',
route,
message: {
id: 'egress-message-001',
channelName: 'discord',
channelId: route.responseTarget.channelId,
senderId: route.logicalAgentId,
senderKind: 'agent',
content,
contentKind: 'markdown',
timestamp: '2026-07-14T12:00:00.000Z',
metadata: {},
},
};
}
describe('official Discord channel routing', () => {
it('normalizes an authorized turn through the shared channel ingress port', async () => {
const receive = vi.fn<(ingress: ChannelIngressDto) => Promise<void>>().mockResolvedValue();
const { internals, emit } = createPlugin('operator', true, { receive });
internals.socket = null;
await internals.handleDiscordMessage(fakeMessage({ content: 'normalized turn' }));
expect(emit).not.toHaveBeenCalled();
expect(receive).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'message.send',
principal: {
channelUserId: 'user-001',
role: 'operator',
mosaicUserId: 'mosaic-user-001',
},
message: expect.objectContaining({
channelName: 'discord',
channelId: 'channel-001',
content: 'normalized turn',
senderKind: 'user',
}),
route: expect.objectContaining({
logicalAgentId: 'Nova',
conversationId: 'Nova:discord:channel-001',
}),
}),
);
});
it('releases a response route when typed ingress rejects', async () => {
const receive = vi.fn().mockRejectedValue(new Error('gateway rejected ingress'));
const { internals } = createPlugin('operator', true, { receive });
await expect(
internals.handleDiscordMessage(fakeMessage({ content: 'rejected typed ingress' })),
).rejects.toThrow('gateway rejected ingress');
expect(internals.conversationRoutes).toHaveLength(0);
});
it('routes an authorized untagged parent-channel message and responds in that channel', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({ content: 'channel conversation' });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN);
expect(payload).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:channel-001',
content: 'channel conversation',
});
expect(payload?.threadId).toBeUndefined();
});
it('creates a thread for a mentioned parent-channel message and routes the response there', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> investigate this topic',
mentioned: true,
createdThreadId: 'thread-created-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).toHaveBeenCalledOnce();
const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN);
expect(payload).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:thread-created-001',
content: 'investigate this topic',
threadId: 'thread-created-001',
});
});
it('fetches and reuses an existing thread that is missing from the cache', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> continue uncached topic',
mentioned: true,
hasThread: true,
fetchedThreadId: 'thread-fetched-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
conversationId: 'Nova:discord:thread-fetched-001',
threadId: 'thread-fetched-001',
});
});
it('delivers the agent response to the thread selected by the mentioned turn', async () => {
const { internals } = createPlugin();
const send = vi.fn().mockResolvedValue(undefined);
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: {
cache: {
get: (id: string): { send(options: unknown): Promise<void> } | undefined =>
id === 'thread-response-001' ? { send } : undefined,
},
},
};
await internals.handleDiscordMessage(
fakeMessage({
content: '<@bot-001> threaded response',
mentioned: true,
createdThreadId: 'thread-response-001',
}),
);
await internals.sendToDiscord('Nova:discord:thread-response-001', 'agent answer');
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ content: 'agent answer', enforceNonce: true }),
);
});
it('reuses a thread already attached to a mentioned message instead of creating another', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> continue existing topic',
mentioned: true,
existingThreadId: 'thread-existing-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
conversationId: 'Nova:discord:thread-existing-001',
threadId: 'thread-existing-001',
});
});
it('keeps an untagged follow-up inside an authorized thread without nesting threads', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: 'thread follow-up',
channelId: 'thread-001',
parentChannelId: 'channel-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:thread-001',
content: 'thread follow-up',
threadId: 'thread-001',
});
});
it.each([
['guild', { guildId: 'guild-not-allowed' }],
['channel', { channelId: 'channel-not-allowed' }],
['user', { userId: 'user-not-allowed' }],
])(
'rejects an unauthorized %s before thread creation or gateway dispatch',
async (_boundary: string, override: Partial<FakeDiscordMessageOptions>) => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> unauthorized topic',
mentioned: true,
...override,
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
},
);
it.each([
['parent channel', {}],
['existing thread', { channelId: 'thread-unpaired-001', parentChannelId: 'channel-001' }],
])(
'rejects an allowlisted but unpaired user in %s',
async (_location: string, override: Partial<FakeDiscordMessageOptions>) => {
const { internals, emit } = createPlugin('operator', false);
const message = fakeMessage({ content: 'unpaired message', ...override });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
},
);
it('rejects a paired viewer before thread creation or gateway dispatch', async () => {
const { internals, emit } = createPlugin('viewer');
const message = fakeMessage({
content: '<@bot-001> viewer cannot send',
mentioned: true,
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
it('uses a normal channel ID rather than its category parent for authorization', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: 'message from categorized channel',
parentChannelId: 'category-001',
isThread: false,
});
await internals.handleDiscordMessage(message);
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:channel-001',
});
});
it.each([
['parent channel', {}],
['existing thread', { channelId: 'thread-attachment-001', parentChannelId: 'channel-001' }],
])(
'preserves an attachment-only turn in an authorized %s',
async (_location: string, override: Partial<FakeDiscordMessageOptions>) => {
const { internals, emit } = createPlugin();
const attachments = new Map<string, FakeDiscordAttachment>([
[
'attachment-001',
{
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.invalid/diagram.png',
contentType: 'image/png',
size: 4_096,
},
],
]);
await internals.handleDiscordMessage(fakeMessage({ content: '', attachments, ...override }));
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
content: '',
attachments: [
{
id: 'attachment-001',
name: 'diagram.png',
contentType: 'image/png',
sizeBytes: 4_096,
},
],
});
},
);
it('does not dispatch when Discord cannot create the requested thread', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({ content: '<@bot-001> new topic', mentioned: true });
message.startThread.mockRejectedValueOnce(new Error('missing thread permission'));
await expect(internals.handleDiscordMessage(message)).rejects.toThrow(
'missing thread permission',
);
expect(emit).not.toHaveBeenCalled();
});
it.each(['/approve', '/stop approval-001'])(
'rejects operator use of privileged command %s before gateway dispatch',
async (command: string) => {
const { internals, emit } = createPlugin('operator');
const message = fakeMessage({ content: `<@bot-001> ${command}`, mentioned: true });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
},
);
it('keeps runtime control commands on the current durable session', async () => {
const { internals, emit } = createPlugin('admin');
const message = fakeMessage({ content: '<@bot-001> /approve', mentioned: true });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
});
it('keeps the stable conversation address independent of a runtime harness', async () => {
const first = createPlugin();
const second = createPlugin();
await first.internals.handleDiscordMessage(
fakeMessage({ id: 'message-claude', content: 'before runtime handoff' }),
);
await second.internals.handleDiscordMessage(
fakeMessage({ id: 'message-pi', content: 'after runtime handoff' }),
);
const firstPayload = verifyDiscordIngressEnvelope(emittedEnvelope(first.emit), SERVICE_TOKEN);
const secondPayload = verifyDiscordIngressEnvelope(emittedEnvelope(second.emit), SERVICE_TOKEN);
expect(firstPayload?.conversationId).toBe('Nova:discord:channel-001');
expect(secondPayload?.conversationId).toBe(firstPayload?.conversationId);
});
it('rate-limits authorized turns before thread creation or dispatch', async () => {
const { internals, emit } = createPlugin('operator', true, undefined, {
messageRateLimitPerMinute: 1,
threadRateLimitPerMinute: 1,
});
const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined);
const first = fakeMessage({ id: 'rate-first', content: 'first turn' });
const second = fakeMessage({
id: 'rate-second',
content: '<@bot-001> second turn',
mentioned: true,
});
await internals.handleDiscordMessage(first);
await internals.handleDiscordMessage(second);
expect(emit).toHaveBeenCalledOnce();
expect(second.startThread).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(expect.stringContaining('Message rate limit reached'));
});
it('applies a stricter mention-thread rate limit before Discord side effects', async () => {
const { internals, emit } = createPlugin('operator', true, undefined, {
messageRateLimitPerMinute: 10,
threadRateLimitPerMinute: 1,
});
vi.spyOn(console, 'error').mockImplementation((): void => undefined);
const first = fakeMessage({ id: 'thread-rate-first', content: 'first topic', mentioned: true });
const second = fakeMessage({
id: 'thread-rate-second',
content: 'second topic',
mentioned: true,
});
await internals.handleDiscordMessage(first);
await internals.handleDiscordMessage(second);
expect(first.startThread).toHaveBeenCalledOnce();
expect(second.startThread).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledOnce();
});
it('rejects unconfigured egress and handles missing or failed Discord destinations', async () => {
const { plugin, internals } = createPlugin();
const route: ChannelConversationRouteDto = {
bindingId: 'guild-001:channel-001:Nova',
logicalAgentId: 'Nova',
conversationId: 'Nova:discord:channel-001',
channelName: 'discord',
authorizationChannelId: 'channel-001',
responseTarget: { channelId: 'channel-001' },
};
await expect(
plugin.send(
egressFor({
...route,
bindingId: 'forged-binding',
}),
),
).rejects.toMatchObject({ code: 'invalid_route' });
await expect(
plugin.send(
egressFor({
...route,
conversationId: 'Nova:discord:unrelated-conversation',
}),
),
).rejects.toMatchObject({ code: 'invalid_route' });
await expect(
plugin.send({
...egressFor(route),
message: { ...egressFor(route).message, channelId: 'other-channel' },
}),
).rejects.toMatchObject({ code: 'invalid_route' });
const forgedSend = vi.fn().mockResolvedValue(undefined);
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: {
cache: {
get: (): { send(options: unknown): Promise<void> } => ({ send: forgedSend }),
},
},
};
await expect(
plugin.send(
egressFor({
...route,
conversationId: 'Nova:discord:forged-channel',
responseTarget: { channelId: 'forged-channel', threadId: 'forged-channel' },
}),
),
).rejects.toMatchObject({ code: 'invalid_route' });
expect(forgedSend).not.toHaveBeenCalled();
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: { cache: { get: (): undefined => undefined } },
};
await expect(plugin.send(egressFor(route))).rejects.toMatchObject({
code: 'destination_unavailable',
});
await expect(
internals.sendToDiscord('missing-conversation', 'missing route'),
).rejects.toMatchObject({ code: 'invalid_route' });
vi.useFakeTimers();
const send = vi
.fn()
.mockRejectedValue(Object.assign(new Error('Discord unavailable'), { status: 503 }));
internals.client.channels = {
cache: { get: (): { send(options: unknown): Promise<void> } => ({ send }) },
};
const delivery = plugin.send(egressFor(route));
const rejection = expect(delivery).rejects.toMatchObject({
code: 'delivery_failed',
retryable: true,
});
await vi.runAllTimersAsync();
await rejection;
expect(send).toHaveBeenCalledTimes(3);
expect(new Set(send.mock.calls.map(([options]) => options.nonce)).size).toBe(1);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ enforceNonce: true, content: 'agent response' }),
);
const permanentSend = vi
.fn()
.mockRejectedValue(Object.assign(new Error('Discord forbidden'), { status: 403 }));
internals.client.channels = {
cache: {
get: (): { send(options: unknown): Promise<void> } => ({ send: permanentSend }),
},
};
await expect(plugin.send(egressFor(route))).rejects.toMatchObject({
code: 'delivery_failed',
retryable: false,
});
expect(permanentSend).toHaveBeenCalledOnce();
});
it('chunks long egress responses at Discord-safe boundaries', async () => {
const { plugin, internals } = createPlugin();
const send = vi.fn().mockResolvedValue(undefined);
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: {
cache: { get: (): { send(options: unknown): Promise<void> } => ({ send }) },
},
};
const route: ChannelConversationRouteDto = {
bindingId: 'guild-001:channel-001:Nova',
logicalAgentId: 'Nova',
conversationId: 'Nova:discord:channel-001',
channelName: 'discord',
authorizationChannelId: 'channel-001',
responseTarget: { channelId: 'channel-001' },
};
await plugin.send(egressFor(route, `${'a'.repeat(1_500)}\n${'b'.repeat(1_500)}`));
expect(send).toHaveBeenCalledTimes(2);
});
it('reports channel adapter health without exposing runtime-provider state', async () => {
const { plugin, internals } = createPlugin();
expect(await plugin.health()).toEqual({ status: 'connected' });
internals.socket = { connected: false, emit: vi.fn() };
expect(await plugin.health()).toEqual({ status: 'degraded' });
internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => false };
expect(await plugin.health()).toEqual({ status: 'disconnected' });
internals.socket = { connected: true, emit: vi.fn() };
expect(await plugin.health()).toEqual({ status: 'degraded' });
});
});

View File

@@ -1,10 +1,38 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js'; import { ChannelDeliveryError } from '@mosaicstack/types';
import type {
ChannelAdapterHealthDto,
ChannelAuthorizedPrincipalDto,
ChannelConversationRouteDto,
ChannelEgressDto,
ChannelEgressPort,
ChannelIngressDto,
ChannelIngressPort,
ChannelMessageDto,
OfficialChannelAdapter,
} from '@mosaicstack/types';
import {
ChannelType,
Client,
GatewayIntentBits,
ThreadAutoArchiveDuration,
type Message as DiscordMessage,
} from 'discord.js';
import { io, type Socket } from 'socket.io-client'; import { io, type Socket } from 'socket.io-client';
export interface DiscordPluginDependencies {
ingressPort?: ChannelIngressPort;
client?: Client;
socketFactory?: (url: string, options: Parameters<typeof io>[1]) => Socket;
}
export interface DiscordPluginConfig { export interface DiscordPluginConfig {
token: string; token: string;
gatewayUrl: string; gatewayUrl: string;
/** Maximum authorized turns per Discord user/channel per minute. */
messageRateLimitPerMinute?: number;
/** Maximum mention-triggered thread routes per Discord user/channel per minute. */
threadRateLimitPerMinute?: number;
/** Shared service credential injected by the approved secret mechanism. */ /** Shared service credential injected by the approved secret mechanism. */
serviceToken: string; serviceToken: string;
/** Which guild to bind to (single-guild only for v0.1.0). */ /** Which guild to bind to (single-guild only for v0.1.0). */
@@ -30,13 +58,23 @@ export interface DiscordInteractionUserBinding {
export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding; export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding;
export interface DiscordInteractionBinding { export interface DiscordInteractionBinding {
/** Stable logical agent identity used in channel conversation routes. */
instanceId: string; instanceId: string;
/** Trusted gateway database agent-config ID selected for this binding. */
agentConfigId: string;
guildId: string; guildId: string;
channelId: string; channelId: string;
/** Pairing roster keyed by Discord user ID. */ /** Pairing roster keyed by Discord user ID. */
pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>; pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>;
} }
const DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE = 30;
const DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE = 5;
const RATE_LIMIT_WINDOW_MS = 60_000;
const DELIVERY_MAX_ATTEMPTS = 3;
const DELIVERY_RETRY_BASE_MS = 50;
const MAX_CONVERSATION_ROUTES = 1_000;
const operationRoles: Readonly< const operationRoles: Readonly<
Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]> Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
> = { > = {
@@ -90,6 +128,7 @@ export function parseDiscordInteractionBindings(
const candidate = binding as Partial<DiscordInteractionBinding>; const candidate = binding as Partial<DiscordInteractionBinding>;
if ( if (
!candidate.instanceId || !candidate.instanceId ||
!candidate.agentConfigId ||
!candidate.guildId || !candidate.guildId ||
!candidate.channelId || !candidate.channelId ||
!candidate.pairedUsers || !candidate.pairedUsers ||
@@ -130,6 +169,7 @@ export function parseDiscordInteractionBindings(
) as Record<string, DiscordInteractionPairing>; ) as Record<string, DiscordInteractionPairing>;
return { return {
instanceId: candidate.instanceId, instanceId: candidate.instanceId,
agentConfigId: candidate.agentConfigId,
guildId: candidate.guildId, guildId: candidate.guildId,
channelId: candidate.channelId, channelId: candidate.channelId,
pairedUsers, pairedUsers,
@@ -154,6 +194,7 @@ export interface DiscordAttachment {
name: string; name: string;
url: string; url: string;
contentType: string | null; contentType: string | null;
sizeBytes?: number;
} }
export interface DiscordIngressEnvelope { export interface DiscordIngressEnvelope {
@@ -229,27 +270,37 @@ export function verifyDiscordIngressEnvelope(
return envelope.payload; return envelope.payload;
} }
export class DiscordPlugin { export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort {
readonly name = 'discord';
private client: Client; private client: Client;
private socket: Socket | null = null; private socket: Socket | null = null;
/** Map Discord channel ID → Mosaic conversation ID. */ /** Bounded last-authorized routes for response-target egress validation. */
private channelConversations = new Map<string, string>(); private conversationRoutes = new Map<string, ChannelConversationRouteDto>();
/** Track in-flight responses to avoid duplicate streaming. */ /** Track in-flight responses to avoid duplicate streaming. */
private pendingResponses = new Map<string, string>(); private pendingResponses = new Map<string, string>();
private readonly messageRateWindows = new Map<string, number[]>();
private readonly threadRateWindows = new Map<string, number[]>();
constructor(private readonly config: DiscordPluginConfig) { constructor(
this.client = new Client({ private readonly config: DiscordPluginConfig,
intents: [ private readonly dependencies: DiscordPluginDependencies = {},
GatewayIntentBits.Guilds, ) {
GatewayIntentBits.GuildMessages, this.client =
GatewayIntentBits.MessageContent, dependencies.client ??
GatewayIntentBits.DirectMessages, new Client({
], intents: [
}); GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});
} }
async start(): Promise<void> { async start(): Promise<void> {
this.socket = io(`${this.config.gatewayUrl}/chat`, { const socketFactory = this.dependencies.socketFactory ?? io;
this.socket = socketFactory(`${this.config.gatewayUrl}/chat`, {
auth: { discordServiceToken: this.config.serviceToken }, auth: { discordServiceToken: this.config.serviceToken },
transports: ['websocket'], transports: ['websocket'],
}); });
@@ -276,11 +327,13 @@ export class DiscordPlugin {
this.socket.on('agent:end', (data: { conversationId: string }) => { this.socket.on('agent:end', (data: { conversationId: string }) => {
const text = this.pendingResponses.get(data.conversationId); const text = this.pendingResponses.get(data.conversationId);
this.pendingResponses.delete(data.conversationId);
if (text) { if (text) {
this.pendingResponses.delete(data.conversationId); this.sendAgentResponse(data.conversationId, text).catch((err: unknown) => {
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
console.error(`[discord] Error sending response for ${data.conversationId}:`, err); console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
}); });
} else {
this.conversationRoutes.delete(data.conversationId);
} }
}); });
@@ -288,15 +341,33 @@ export class DiscordPlugin {
this.pendingResponses.set(data.conversationId, ''); this.pendingResponses.set(data.conversationId, '');
}); });
this.client.on('messageCreate', (message: DiscordMessage) => this.socket.on('error', (data: { conversationId?: unknown }) => {
this.handleDiscordMessage(message), if (typeof data.conversationId !== 'string') return;
); this.pendingResponses.delete(data.conversationId);
this.conversationRoutes.delete(data.conversationId);
});
this.client.on('messageCreate', (message: DiscordMessage) => {
void Promise.resolve(this.handleDiscordMessage(message)).catch((error: unknown): void => {
const errorName = error instanceof Error ? error.name : 'UnknownError';
console.error(
`[discord] Message routing failed. channel=${message.channelId} message=${message.id} error=${errorName}`,
);
});
});
this.client.on('ready', () => { this.client.on('ready', () => {
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`); console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
}); });
await this.client.login(this.config.token); try {
await this.client.login(this.config.token);
} catch (error: unknown) {
this.socket.disconnect();
this.socket = null;
await this.client.destroy();
throw error;
}
} }
async stop(): Promise<void> { async stop(): Promise<void> {
@@ -304,6 +375,14 @@ export class DiscordPlugin {
await this.client.destroy(); await this.client.destroy();
} }
async health(): Promise<ChannelAdapterHealthDto> {
const discordReady = this.client.isReady();
const gatewayConnected = this.socket?.connected === true;
if (discordReady && gatewayConnected) return { status: 'connected' };
if (discordReady || gatewayConnected) return { status: 'degraded' };
return { status: 'disconnected' };
}
async createProjectChannel(project: { async createProjectChannel(project: {
id: string; id: string;
name: string; name: string;
@@ -325,77 +404,224 @@ export class DiscordPlugin {
topic: project.description ?? `Mosaic project: ${project.name}`, topic: project.description ?? `Mosaic project: ${project.name}`,
}); });
this.channelConversations.set(channel.id, `discord-${channel.id}`); // A project channel has no logical-agent conversation until a configured
// binding authorizes a message. Do not seed a legacy channel-only key.
return { channelId: channel.id }; return { channelId: channel.id };
} }
private handleDiscordMessage(message: DiscordMessage): void { private handleDiscordMessage(message: DiscordMessage): void | Promise<void> {
if (message.author.bot || !this.client.user) return; if (message.author.bot || !this.client.user || !message.guildId) return;
if (!message.guildId || !this.isAllowedMessage(message)) return;
const authorizationChannelId = this.authorizationChannelId(message);
if (!this.isAllowedMessage(message, authorizationChannelId)) return;
const isMention = message.mentions.has(this.client.user); const isMention = message.mentions.has(this.client.user);
if (!isMention) return; const content = isMention
? message.content.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '').trim()
: message.content.trim();
if (!content && message.attachments.size === 0) return;
const operation = this.interactionOperation(content);
const binding = resolveDiscordInteractionBinding(
this.config.interactionBindings ?? [],
message.guildId,
authorizationChannelId,
message.author.id,
operation,
);
// Pairing and operation-specific role checks happen before thread creation
// or any gateway dispatch, including privileged control events.
if (!binding) return;
const content = message.content const rateKey = `${message.guildId}:${authorizationChannelId}:${message.author.id}`;
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '') if (
.trim(); !this.consumeRateLimit(
if (!content) return; this.messageRateWindows,
if (!this.socket?.connected) { rateKey,
this.config.messageRateLimitPerMinute ?? DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE,
)
) {
console.error(
`[discord] Message rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`,
);
return;
}
const createThread = isMention && operation === 'send';
if (
createThread &&
!this.consumeRateLimit(
this.threadRateWindows,
rateKey,
this.config.threadRateLimitPerMinute ?? DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE,
)
) {
console.error(
`[discord] Thread rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`,
);
return;
}
if (!this.dependencies.ingressPort && !this.socket?.connected) {
console.error( console.error(
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`, `[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
); );
return; return;
} }
const channelId = message.channelId; // Approval/stop commands act on the current durable session. They remain
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null; // at the current channel/thread target rather than creating a new topic.
const bindingChannelId = parentChannelId ?? channelId; const route = this.resolveConversationRoute(
const binding = resolveDiscordInteractionBinding( message,
this.config.interactionBindings ?? [], binding,
message.guildId, authorizationChannelId,
bindingChannelId, createThread,
message.author.id,
'send',
); );
if (!binding) return; if (route instanceof Promise) {
const conversationId = return route.then((resolved: ChannelConversationRouteDto): void | Promise<void> =>
this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`; this.dispatchDiscordIngress(message, content, operation, binding, resolved),
this.channelConversations.set(channelId, conversationId); );
}
return this.dispatchDiscordIngress(message, content, operation, binding, route);
}
private dispatchDiscordIngress(
message: DiscordMessage,
content: string,
operation: 'send' | 'approve' | 'stop',
binding: DiscordInteractionBinding,
route: ChannelConversationRouteDto,
): void | Promise<void> {
const guildId = message.guildId;
if (!guildId) return;
if (operation === 'send') this.rememberConversationRoute(route);
const correlationId = randomUUID();
const ingress: ChannelIngressDto = {
correlationId,
nativeMessageId: message.id,
operation:
operation === 'approve'
? 'approval.create'
: operation === 'stop'
? 'session.stop'
: 'message.send',
principal: this.authorizedPrincipal(binding, message.author.id),
message: this.channelMessage(message, content, route),
route,
};
if (this.dependencies.ingressPort) {
return this.dependencies.ingressPort.receive(ingress).catch((error: unknown) => {
if (operation === 'send') this.conversationRoutes.delete(route.conversationId);
throw error;
});
}
const socket = this.socket;
if (!socket?.connected) {
console.error(
`[discord] Cannot dispatch routed message: gateway disconnected. channel=${message.channelId} message=${message.id}`,
);
return;
}
this.emitDiscordIngress(socket, ingress);
}
private authorizedPrincipal(
binding: DiscordInteractionBinding,
channelUserId: string,
): ChannelAuthorizedPrincipalDto {
const pairing = binding.pairedUsers[channelUserId];
if (!pairing) throw new Error('Authorized Discord pairing is unavailable');
if (typeof pairing === 'string') return { channelUserId, role: pairing };
return {
channelUserId,
role: pairing.role,
...(pairing.mosaicUserId ? { mosaicUserId: pairing.mosaicUserId } : {}),
};
}
private channelMessage(
message: DiscordMessage,
content: string,
route: ChannelConversationRouteDto,
): ChannelMessageDto {
const attachments = Array.from(message.attachments.values()).map((attachment) => ({
id: attachment.id,
name: attachment.name,
url: attachment.url,
mimeType: attachment.contentType,
sizeBytes: attachment.size,
}));
const firstContentType = attachments[0]?.mimeType;
return {
id: randomUUID(),
channelName: this.name,
channelId: route.responseTarget.channelId,
senderId: message.author.id,
senderKind: 'user',
content,
contentKind:
content.length > 0 ? 'markdown' : firstContentType?.startsWith('image/') ? 'image' : 'file',
timestamp:
message.createdAt instanceof Date
? message.createdAt.toISOString()
: new Date().toISOString(),
...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}),
...(attachments.length > 0 ? { attachments } : {}),
metadata: {
channelMessageId: message.id,
guildId: message.guildId ?? '',
},
};
}
private emitDiscordIngress(socket: Socket, ingress: ChannelIngressDto): void {
const envelope = createDiscordIngressEnvelope( const envelope = createDiscordIngressEnvelope(
{ {
correlationId: randomUUID(), correlationId: ingress.correlationId,
messageId: message.id, messageId: ingress.nativeMessageId,
guildId: message.guildId, guildId: String(ingress.message.metadata['guildId'] ?? ''),
channelId: bindingChannelId, channelId: ingress.route.authorizationChannelId,
userId: message.author.id, userId: ingress.principal.channelUserId,
conversationId, conversationId: ingress.route.conversationId,
content, content: ingress.message.content,
threadId: parentChannelId ? channelId : undefined, ...(ingress.route.responseTarget.threadId
attachments: Array.from(message.attachments.values()).map((attachment) => ({ ? { threadId: ingress.route.responseTarget.threadId }
: {}),
attachments: ingress.message.attachments?.map((attachment) => ({
id: attachment.id, id: attachment.id,
name: attachment.name, name: attachment.name,
url: attachment.url, url: attachment.url,
contentType: attachment.contentType, contentType: attachment.mimeType,
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
})), })),
}, },
this.config.serviceToken, this.config.serviceToken,
); );
this.socket.emit( socket.emit(
/^\/approve$/i.test(content) ingress.operation === 'approval.create'
? 'discord:approve' ? 'discord:approve'
: content.startsWith('/stop ') : ingress.operation === 'session.stop'
? 'discord:stop' ? 'discord:stop'
: 'message', : 'message',
envelope, envelope,
); );
} }
private isAllowedMessage(message: DiscordMessage): boolean { private authorizationChannelId(message: DiscordMessage): string {
// A normal guild channel can itself have a category parent. Only Discord
// threads inherit authorization from a configured parent text channel.
const channel = message.channel as DiscordMessage['channel'] & {
isThread?: () => boolean;
parentId?: string | null;
};
const isThread =
typeof channel.isThread === 'function'
? channel.isThread()
: channel.parentId !== undefined && channel.parentId !== null;
return isThread && channel.parentId ? channel.parentId : message.channelId;
}
private isAllowedMessage(message: DiscordMessage, authorizationChannelId: string): boolean {
const guildId = message.guildId; const guildId = message.guildId;
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
// Threads inherit their authorization boundary from their configured parent.
const authorizationChannelId = parentChannelId ?? message.channelId;
return ( return (
guildId !== null && guildId !== null &&
includesId(this.config.allowedGuildIds, guildId) && includesId(this.config.allowedGuildIds, guildId) &&
@@ -404,31 +630,282 @@ export class DiscordPlugin {
); );
} }
private async sendToDiscord(conversationId: string, text: string): Promise<void> { private resolveConversationRoute(
const channelId = Array.from(this.channelConversations.entries()).find( message: DiscordMessage,
([, convId]) => convId === conversationId, binding: DiscordInteractionBinding,
)?.[0]; authorizationChannelId: string,
createThread: boolean,
if (!channelId) { ): ChannelConversationRouteDto | Promise<ChannelConversationRouteDto> {
console.error(`[discord] No channel found for conversation ${conversationId}`); if (authorizationChannelId !== message.channelId) {
return; return this.createConversationRoute(
binding,
authorizationChannelId,
message.channelId,
message.channelId,
);
}
if (!createThread) {
return this.createConversationRoute(binding, authorizationChannelId, message.channelId);
} }
if (message.hasThread) {
const cachedThread = message.thread;
if (cachedThread) {
return this.createConversationRoute(
binding,
authorizationChannelId,
cachedThread.id,
cachedThread.id,
);
}
if (!('threads' in message.channel)) {
return Promise.reject(new Error('Existing Discord thread manager is unavailable'));
}
return message.channel.threads
.fetch(message.id)
.then((thread): ChannelConversationRouteDto => {
if (!thread) throw new Error('Existing Discord thread is unavailable');
return this.createConversationRoute(
binding,
authorizationChannelId,
thread.id,
thread.id,
);
});
}
return message
.startThread({
name: `Mosaic conversation ${message.id.slice(-8)}`,
autoArchiveDuration: ThreadAutoArchiveDuration.OneHour,
reason: 'Authorized Mosaic mention',
})
.then(
(thread): ChannelConversationRouteDto =>
this.createConversationRoute(binding, authorizationChannelId, thread.id, thread.id),
);
}
private createConversationRoute(
binding: DiscordInteractionBinding,
authorizationChannelId: string,
responseChannelId: string,
threadId?: string,
): ChannelConversationRouteDto {
// Recompute from configuration on every turn so a stale in-memory map can
// never carry a channel-only or differently bound agent identity forward.
const conversationId = `${binding.instanceId}:discord:${responseChannelId}`;
return {
bindingId: `${binding.guildId}:${binding.channelId}:${binding.instanceId}`,
logicalAgentId: binding.instanceId,
conversationId,
channelName: this.name,
authorizationChannelId,
responseTarget: {
channelId: responseChannelId,
...(threadId ? { threadId } : {}),
},
};
}
private consumeRateLimit(
windows: Map<string, number[]>,
key: string,
limit: number,
now = Date.now(),
): boolean {
const active = (windows.get(key) ?? []).filter(
(timestamp: number): boolean => now - timestamp < RATE_LIMIT_WINDOW_MS,
);
if (active.length >= Math.max(1, limit)) {
windows.set(key, active);
return false;
}
active.push(now);
windows.set(key, active);
return true;
}
private interactionOperation(content: string): 'send' | 'approve' | 'stop' {
if (/^\/approve$/i.test(content)) return 'approve';
if (/^\/stop\s+\S+/i.test(content)) return 'stop';
return 'send';
}
async send(egress: ChannelEgressDto): Promise<void> {
if (!this.isConfiguredRoute(egress.route) || !this.isMessageAlignedWithRoute(egress)) {
throw new ChannelDeliveryError(
'invalid_route',
`Discord egress route is not authorized for conversation ${egress.route.conversationId}`,
);
}
const channelId = egress.route.responseTarget.channelId;
const channel = this.client.channels.cache.get(channelId); const channel = this.client.channels.cache.get(channelId);
if (!channel || !('send' in channel)) { if (!channel || !('send' in channel)) {
console.error( throw new ChannelDeliveryError(
`[discord] Channel ${channelId} not sendable for conversation ${conversationId}`, 'destination_unavailable',
`Discord destination is unavailable for conversation ${egress.route.conversationId}`,
); );
return;
} }
for (const chunk of this.chunkText(text, 1900)) { const chunks = this.chunkText(egress.message.content, 1900);
for (const [chunkIndex, chunk] of chunks.entries()) {
await this.sendChunkWithRetry(
channel as {
send(options: { content: string; nonce: string; enforceNonce: true }): Promise<unknown>;
},
chunk,
egress.correlationId,
chunkIndex,
egress.route.conversationId,
);
}
}
private async sendChunkWithRetry(
channel: {
send(options: { content: string; nonce: string; enforceNonce: true }): Promise<unknown>;
},
chunk: string,
correlationId: string,
chunkIndex: number,
conversationId: string,
): Promise<void> {
const nonce = createHash('sha256')
.update(`${correlationId}:${chunkIndex}`)
.digest('hex')
.slice(0, 25);
let lastError: unknown;
for (let attempt = 1; attempt <= DELIVERY_MAX_ATTEMPTS; attempt += 1) {
try { try {
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk); await channel.send({ content: chunk, nonce, enforceNonce: true });
} catch (err: unknown) { return;
console.error(`[discord] Failed to send message to channel ${channelId}:`, err); } catch (error: unknown) {
lastError = error;
const retryable = this.isTransientDeliveryError(error);
if (!retryable) {
throw new ChannelDeliveryError(
'delivery_failed',
`Discord delivery failed for conversation ${conversationId}`,
false,
{ cause: error },
);
}
if (attempt < DELIVERY_MAX_ATTEMPTS) {
await new Promise<void>((resolve): void => {
setTimeout(resolve, DELIVERY_RETRY_BASE_MS * 2 ** (attempt - 1));
});
}
} }
} }
throw new ChannelDeliveryError(
'delivery_failed',
`Discord delivery failed for conversation ${conversationId}`,
true,
{ cause: lastError },
);
}
private isTransientDeliveryError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const candidate = error as { status?: unknown; code?: unknown };
if (
typeof candidate.status === 'number' &&
(candidate.status === 429 || candidate.status >= 500)
) {
return true;
}
return (
typeof candidate.code === 'string' &&
['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(candidate.code)
);
}
private async sendAgentResponse(conversationId: string, text: string): Promise<void> {
const route = this.conversationRoutes.get(conversationId);
if (!route) {
throw new ChannelDeliveryError(
'invalid_route',
`Discord response route is unavailable for conversation ${conversationId}`,
);
}
try {
await this.send({
correlationId: randomUUID(),
route,
message: {
id: randomUUID(),
channelName: this.name,
channelId: route.responseTarget.channelId,
senderId: route.logicalAgentId,
senderKind: 'agent',
content: text,
contentKind: 'markdown',
timestamp: new Date().toISOString(),
...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}),
metadata: {},
},
});
} finally {
this.conversationRoutes.delete(conversationId);
}
}
/** Compatibility wrapper while Socket.IO agent events carry only conversation ID. */
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
await this.sendAgentResponse(conversationId, text);
}
private rememberConversationRoute(route: ChannelConversationRouteDto): void {
if (
!this.conversationRoutes.has(route.conversationId) &&
this.conversationRoutes.size >= MAX_CONVERSATION_ROUTES
) {
throw new ChannelDeliveryError(
'delivery_failed',
'Discord has reached its active response-route limit',
true,
);
}
this.conversationRoutes.set(route.conversationId, route);
}
private isConfiguredRoute(route: ChannelConversationRouteDto): boolean {
if (
route.channelName !== this.name ||
route.conversationId !==
`${route.logicalAgentId}:${this.name}:${route.responseTarget.channelId}`
) {
return false;
}
const bindingMatches = (this.config.interactionBindings ?? []).some(
(binding): boolean =>
route.bindingId === `${binding.guildId}:${binding.channelId}:${binding.instanceId}` &&
route.logicalAgentId === binding.instanceId &&
route.authorizationChannelId === binding.channelId,
);
if (!bindingMatches) return false;
if (
route.responseTarget.channelId === route.authorizationChannelId &&
route.responseTarget.threadId === undefined
) {
return true;
}
const observed = this.conversationRoutes.get(route.conversationId);
return (
observed?.bindingId === route.bindingId &&
observed.logicalAgentId === route.logicalAgentId &&
observed.authorizationChannelId === route.authorizationChannelId &&
observed.responseTarget.channelId === route.responseTarget.channelId &&
observed.responseTarget.threadId === route.responseTarget.threadId
);
}
private isMessageAlignedWithRoute(egress: ChannelEgressDto): boolean {
return (
egress.message.channelName === this.name &&
egress.message.channelId === egress.route.responseTarget.channelId &&
egress.message.threadId === egress.route.responseTarget.threadId
);
} }
private chunkText(text: string, maxLength: number): string[] { private chunkText(text: string, maxLength: number): string[] {

View File

@@ -4,5 +4,15 @@ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 85,
functions: 85,
branches: 85,
statements: 85,
},
},
}, },
}); });

41
pnpm-lock.yaml generated
View File

@@ -732,6 +732,9 @@ importers:
plugins/discord: plugins/discord:
dependencies: dependencies:
'@mosaicstack/types':
specifier: workspace:^
version: link:../../packages/types
discord.js: discord.js:
specifier: ^14.16.0 specifier: ^14.16.0
version: 14.25.1 version: 14.25.1
@@ -739,6 +742,9 @@ importers:
specifier: ^4.8.0 specifier: ^4.8.0
version: 4.8.3 version: 4.8.3
devDependencies: devDependencies:
'@vitest/coverage-v8':
specifier: ^2.0.0
version: 2.1.9(vitest@2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))
tsx: tsx:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.21.0 version: 4.21.0
@@ -1169,11 +1175,11 @@ packages:
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is' deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild-kit/esm-loader@2.6.5': '@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
deprecated: 'Merged into tsx: https://tsx.is' deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
@@ -2160,47 +2166,57 @@ packages:
'@mariozechner/pi-agent-core@0.63.1': '@mariozechner/pi-agent-core@0.63.1':
resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==} resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-agent-core instead going forward
'@mariozechner/pi-agent-core@0.63.2': '@mariozechner/pi-agent-core@0.63.2':
resolution: {integrity: sha512-9QTS7ylcmoAIWXk0EVpwCCop3fK4NIqTAN8TiRuXvuKYx+wYmUJc+P5+RfehIZhwsy7g9O/rktz0c1YEUBFB0g==} resolution: {integrity: sha512-9QTS7ylcmoAIWXk0EVpwCCop3fK4NIqTAN8TiRuXvuKYx+wYmUJc+P5+RfehIZhwsy7g9O/rktz0c1YEUBFB0g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-agent-core instead going forward
'@mariozechner/pi-agent-core@0.65.0': '@mariozechner/pi-agent-core@0.65.0':
resolution: {integrity: sha512-QCDqkgxvCkizCgJOl0aFekT1gURppznzuBIGXS8dXWZMour/xX6YF7chxX56mZ0p0DXkILM1ixf5jXYBfDsP5w==} resolution: {integrity: sha512-QCDqkgxvCkizCgJOl0aFekT1gURppznzuBIGXS8dXWZMour/xX6YF7chxX56mZ0p0DXkILM1ixf5jXYBfDsP5w==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-agent-core instead going forward
'@mariozechner/pi-ai@0.63.1': '@mariozechner/pi-ai@0.63.1':
resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==} resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-ai instead going forward
hasBin: true hasBin: true
'@mariozechner/pi-ai@0.63.2': '@mariozechner/pi-ai@0.63.2':
resolution: {integrity: sha512-EJNPyzeZeifTJmkD8PPYQmSO4P4h8kFCrhUqU4NvFUkug+GNYr954KlxhYnXH0f77MpdIEpf/O5zdDrYJQyafA==} resolution: {integrity: sha512-EJNPyzeZeifTJmkD8PPYQmSO4P4h8kFCrhUqU4NvFUkug+GNYr954KlxhYnXH0f77MpdIEpf/O5zdDrYJQyafA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-ai instead going forward
hasBin: true hasBin: true
'@mariozechner/pi-ai@0.65.0': '@mariozechner/pi-ai@0.65.0':
resolution: {integrity: sha512-MsCsCHlHIlBYbg6jB2PJBeCNKbjzVZge7ddBNUJN2gsFY8sdjFh482+GB+r5Ou6k9Fnhi3nO779YDymo5+t89w==} resolution: {integrity: sha512-MsCsCHlHIlBYbg6jB2PJBeCNKbjzVZge7ddBNUJN2gsFY8sdjFh482+GB+r5Ou6k9Fnhi3nO779YDymo5+t89w==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-ai instead going forward
hasBin: true hasBin: true
'@mariozechner/pi-coding-agent@0.63.1': '@mariozechner/pi-coding-agent@0.63.1':
resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==} resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==}
engines: {node: '>=20.6.0'} engines: {node: '>=20.6.0'}
deprecated: please use @earendil-works/pi-coding-agent instead going forward
hasBin: true hasBin: true
'@mariozechner/pi-coding-agent@0.65.0': '@mariozechner/pi-coding-agent@0.65.0':
resolution: {integrity: sha512-IEBZ74n17w8NxnG/X2ixErsSYcvLm/h5WKALNbPgPWJZqvafNtJ0GcrCfLCS6RVIq2o+O/a2QwsbSI6bgJ6W/A==} resolution: {integrity: sha512-IEBZ74n17w8NxnG/X2ixErsSYcvLm/h5WKALNbPgPWJZqvafNtJ0GcrCfLCS6RVIq2o+O/a2QwsbSI6bgJ6W/A==}
engines: {node: '>=20.6.0'} engines: {node: '>=20.6.0'}
deprecated: please use @earendil-works/pi-coding-agent instead going forward
hasBin: true hasBin: true
'@mariozechner/pi-tui@0.63.1': '@mariozechner/pi-tui@0.63.1':
resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==} resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-tui instead going forward
'@mariozechner/pi-tui@0.65.0': '@mariozechner/pi-tui@0.65.0':
resolution: {integrity: sha512-P5Uuf4x1sTplMNQw8NrC1Hyz0N/tZq9kC6CDRkTT7rZuxZEeXl9uhKvlLEGigdKVOVrWnPE7ip0jrO81POYy3g==} resolution: {integrity: sha512-P5Uuf4x1sTplMNQw8NrC1Hyz0N/tZq9kC6CDRkTT7rZuxZEeXl9uhKvlLEGigdKVOVrWnPE7ip0jrO81POYy3g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-tui instead going forward
'@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0':
resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==}
@@ -3915,6 +3931,7 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@vitest/coverage-v8@2.1.9': '@vitest/coverage-v8@2.1.9':
resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==}
@@ -4095,6 +4112,7 @@ packages:
basic-ftp@5.2.0: basic-ftp@5.2.0:
resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
deprecated: Security vulnerability fixed in 5.2.1, please upgrade
better-auth@1.5.5: better-auth@1.5.5:
resolution: {integrity: sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg==} resolution: {integrity: sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg==}
@@ -7087,6 +7105,7 @@ packages:
uuid@9.0.1: uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true hasBin: true
validator@13.15.26: validator@13.15.26:
@@ -10977,6 +10996,24 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 0.2.3
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magic-string: 0.30.21
magicast: 0.3.5
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 1.2.0
vitest: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
transitivePeerDependencies:
- supports-color
'@vitest/expect@2.1.9': '@vitest/expect@2.1.9':
dependencies: dependencies:
'@vitest/spy': 2.1.9 '@vitest/spy': 2.1.9