Compare commits
2 Commits
main
...
17932c3fb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17932c3fb1 | ||
|
|
83a96b0081 |
@@ -72,7 +72,6 @@ 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: {
|
||||||
@@ -134,7 +133,6 @@ 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: {
|
||||||
|
|||||||
@@ -115,18 +115,6 @@ 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,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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';
|
||||||
@@ -44,8 +43,6 @@ 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 {
|
||||||
@@ -431,7 +428,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}${this.attachmentContext(msg.attachments ?? [])}`;
|
return `**${roleLabel}:** ${msg.content}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatted = history.map((msg) => formatMessage(msg));
|
const formatted = history.map((msg) => formatMessage(msg));
|
||||||
@@ -490,21 +487,6 @@ 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;
|
||||||
@@ -691,19 +673,7 @@ 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}`);
|
||||||
@@ -711,16 +681,12 @@ 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}${attachmentContext}`;
|
let effectiveMessage = message;
|
||||||
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${effectiveMessage}`;
|
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,38 +115,6 @@ describe('Hermes runtime provider reachability', (): void => {
|
|||||||
await app?.close();
|
await app?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise<void> => {
|
|
||||||
if (!app) throw new Error('Nest application did not initialize');
|
|
||||||
|
|
||||||
const attachDenied = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/interaction/Nova/sessions/session-1/attach',
|
|
||||||
headers: { 'x-correlation-id': 'correlation-1' },
|
|
||||||
payload: { mode: 'read' },
|
|
||||||
});
|
|
||||||
expect(attachDenied.statusCode).toBe(401);
|
|
||||||
|
|
||||||
const sendDenied = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/interaction/Nova/sessions/session-1/send',
|
|
||||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(sendDenied.statusCode).toBe(403);
|
|
||||||
expect(sendDenied.json()).toMatchObject({
|
|
||||||
message: 'Content and idempotency key are required',
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopDenied = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/interaction/Nova/sessions/session-1/stop',
|
|
||||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(stopDenied.statusCode).toBe(403);
|
|
||||||
expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
|
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
|
||||||
if (!app) throw new Error('Nest application did not initialize');
|
if (!app) throw new Error('Nest application did not initialize');
|
||||||
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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 {
|
||||||
@@ -33,7 +32,4 @@ export class ChatSocketMessageDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
|
||||||
/** Validated channel attachment references; binary content is not embedded. */
|
|
||||||
attachments?: readonly ChannelAttachmentDto[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ 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;
|
||||||
@@ -44,7 +40,6 @@ 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: '',
|
||||||
@@ -52,7 +47,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(clientConversationKey(client.id, CONVERSATION_ID), session);
|
gateway.clientSessions.set(client.id, session);
|
||||||
|
|
||||||
gateway.relayEvent(client, CONVERSATION_ID, {
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
type: 'message_update',
|
type: 'message_update',
|
||||||
@@ -144,51 +139,6 @@ 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 = {
|
||||||
@@ -197,8 +147,7 @@ describe('ChatGateway redaction boundary', (): void => {
|
|||||||
data: { user: { id: 'user-1' } },
|
data: { user: { id: 'user-1' } },
|
||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
};
|
};
|
||||||
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), {
|
gateway.clientSessions.set(client.id, {
|
||||||
clientId: client.id,
|
|
||||||
conversationId: CONVERSATION_ID,
|
conversationId: CONVERSATION_ID,
|
||||||
cleanup: vi.fn(),
|
cleanup: vi.fn(),
|
||||||
assistantText: CANARY,
|
assistantText: CANARY,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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';
|
||||||
@@ -31,7 +30,6 @@ 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 {
|
||||||
@@ -58,7 +56,6 @@ 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. */
|
||||||
@@ -79,68 +76,6 @@ 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;
|
||||||
@@ -153,8 +88,7 @@ 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['correlationId'],
|
||||||
payload['messageId'],
|
payload['messageId'],
|
||||||
payload['guildId'],
|
payload['guildId'],
|
||||||
@@ -162,40 +96,15 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo
|
|||||||
payload['userId'],
|
payload['userId'],
|
||||||
payload['conversationId'],
|
payload['conversationId'],
|
||||||
payload['content'],
|
payload['content'],
|
||||||
].every((field: unknown): boolean => typeof field === 'string') &&
|
].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 {
|
const payload = value as { content?: unknown; conversationId?: unknown };
|
||||||
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))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,24 +174,20 @@ 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}`);
|
||||||
for (const [key, session] of this.clientSessions) {
|
const session = this.clientSessions.get(client.id);
|
||||||
if (session.clientId !== client.id) continue;
|
if (session) {
|
||||||
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(key);
|
this.clientSessions.delete(client.id);
|
||||||
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);
|
||||||
private clientConversationKey(client: Pick<Socket, 'id'>, conversationId: string): string {
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
|
||||||
return `${client.id}\u0000${conversationId}`;
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getClientScope(client: Socket): ActorTenantScope | null {
|
private getClientScope(client: Socket): ActorTenantScope | null {
|
||||||
@@ -313,25 +218,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
discordIngress = this.resolveDiscordIngress(client, rawData);
|
discordIngress = this.resolveDiscordIngress(client, rawData);
|
||||||
if (!discordIngress) return;
|
if (!discordIngress) return;
|
||||||
data = {
|
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
|
||||||
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}`);
|
||||||
@@ -340,7 +227,6 @@ 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(
|
||||||
@@ -395,7 +281,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 && !discordIngress) {
|
} else if (!resolvedProvider && !resolvedModelId) {
|
||||||
// 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);
|
||||||
@@ -418,24 +304,12 @@ 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: resolvedAgentConfigId,
|
agentConfigId: data.agentId,
|
||||||
userId,
|
userId,
|
||||||
tenantId: scope.tenantId,
|
tenantId: scope.tenantId,
|
||||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
@@ -486,17 +360,6 @@ 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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -511,7 +374,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(clientConversationKey);
|
const existing = this.clientSessions.get(client.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.cleanup();
|
existing.cleanup();
|
||||||
}
|
}
|
||||||
@@ -526,11 +389,10 @@ 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(clientConversationKey);
|
const prevClientSession = this.clientSessions.get(client.id);
|
||||||
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
|
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
|
||||||
|
|
||||||
this.clientSessions.set(clientConversationKey, {
|
this.clientSessions.set(client.id, {
|
||||||
clientId: client.id,
|
|
||||||
conversationId,
|
conversationId,
|
||||||
cleanup,
|
cleanup,
|
||||||
assistantText: '',
|
assistantText: '',
|
||||||
@@ -576,7 +438,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
// Dispatch to agent
|
// Dispatch to agent
|
||||||
try {
|
try {
|
||||||
await this.agentService.prompt(conversationId, data.content, scope, data.attachments);
|
await this.agentService.prompt(conversationId, data.content, scope);
|
||||||
} 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}`,
|
||||||
@@ -783,9 +645,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 session of this.clientSessions.values()) {
|
for (const [clientId, session] of this.clientSessions) {
|
||||||
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(session.clientId);
|
const socket = this.server.sockets.sockets.get(clientId);
|
||||||
if (socket?.connected) {
|
if (socket?.connected) {
|
||||||
socket.emit('session:info', payload);
|
socket.emit('session:info', payload);
|
||||||
}
|
}
|
||||||
@@ -815,10 +677,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
!this.durableSessions
|
!this.durableSessions
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const binding = this.discordBindingFor(ingress, 'approve');
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
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 = binding?.instanceId;
|
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
if (!actorId || !agentName) {
|
if (!actorId || !agentName || binding.instanceId !== 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}`,
|
||||||
);
|
);
|
||||||
@@ -906,7 +774,13 @@ 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 = this.discordBindingFor(ingress, 'stop');
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -980,18 +854,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const binding = this.discordBindingFor(payload, operation);
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
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}`,
|
||||||
@@ -1007,19 +880,6 @@ 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(',')
|
||||||
@@ -1098,15 +958,11 @@ 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) => ({
|
||||||
const attachments = this.persistedChannelAttachments(msg.metadata);
|
|
||||||
return {
|
|
||||||
role: msg.role as 'user' | 'assistant' | 'system',
|
role: msg.role as 'user' | 'assistant' | 'system',
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
createdAt: msg.createdAt,
|
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}`,
|
||||||
@@ -1116,14 +972,6 @@ 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,
|
||||||
@@ -1131,19 +979,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
buffers: Map<string, string>,
|
buffers: Map<string, string>,
|
||||||
delta: string,
|
delta: string,
|
||||||
): void {
|
): void {
|
||||||
const sessionKey = this.clientConversationKey(client, conversationId);
|
const key = this.egressKey(client, eventName);
|
||||||
const key = this.egressKey(client, conversationId, eventName);
|
|
||||||
if (this.overflowedEgress.has(key)) return;
|
if (this.overflowedEgress.has(key)) return;
|
||||||
|
|
||||||
const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`;
|
const buffered = `${buffers.get(client.id) ?? ''}${delta}`;
|
||||||
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
|
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
|
||||||
buffers.delete(sessionKey);
|
buffers.delete(client.id);
|
||||||
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(sessionKey, buffered);
|
buffers.set(client.id, buffered);
|
||||||
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
|
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,22 +1006,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
buffers: Map<string, string>,
|
buffers: Map<string, string>,
|
||||||
final: boolean,
|
final: boolean,
|
||||||
): void {
|
): void {
|
||||||
const sessionKey = this.clientConversationKey(client, conversationId);
|
const key = this.egressKey(client, eventName);
|
||||||
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(sessionKey) ?? '';
|
const buffered = buffers.get(client.id) ?? '';
|
||||||
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(sessionKey, pending);
|
buffers.set(client.id, pending);
|
||||||
} else {
|
} else {
|
||||||
buffers.delete(sessionKey);
|
buffers.delete(client.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (released) {
|
if (released) {
|
||||||
@@ -1243,12 +1089,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
return retainedFrom;
|
return retainedFrom;
|
||||||
}
|
}
|
||||||
|
|
||||||
private egressKey(
|
private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string {
|
||||||
client: Socket,
|
return `${client.id}:${eventName}`;
|
||||||
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 {
|
||||||
@@ -1259,27 +1101,26 @@ 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(sessionKey);
|
const cs = this.clientSessions.get(client.id);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
cs.assistantText = '';
|
cs.assistantText = '';
|
||||||
cs.toolCalls = [];
|
cs.toolCalls = [];
|
||||||
cs.pendingToolCalls.clear();
|
cs.pendingToolCalls.clear();
|
||||||
}
|
}
|
||||||
this.textEgressBuffers.set(sessionKey, '');
|
this.textEgressBuffers.set(client.id, '');
|
||||||
this.thinkingEgressBuffers.set(sessionKey, '');
|
this.thinkingEgressBuffers.set(client.id, '');
|
||||||
this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text'));
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
|
||||||
this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:thinking'));
|
this.overflowedEgress.delete(this.egressKey(client, '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(sessionKey);
|
const activeClientSession = this.clientSessions.get(client.id);
|
||||||
const agentSession = activeClientSession
|
const agentSession = activeClientSession
|
||||||
? this.agentService.getSession(conversationId, activeClientSession.scope)
|
? this.agentService.getSession(conversationId, activeClientSession.scope)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1332,7 +1173,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(sessionKey);
|
const cs = this.clientSessions.get(client.id);
|
||||||
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> = {
|
||||||
@@ -1384,7 +1225,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(sessionKey);
|
const cs = this.clientSessions.get(client.id);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
cs.assistantText += assistantEvent.delta;
|
cs.assistantText += assistantEvent.delta;
|
||||||
}
|
}
|
||||||
@@ -1409,7 +1250,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(sessionKey);
|
const cs = this.clientSessions.get(client.id);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
cs.pendingToolCalls.set(event.toolCallId, {
|
cs.pendingToolCalls.set(event.toolCallId, {
|
||||||
toolName: event.toolName,
|
toolName: event.toolName,
|
||||||
@@ -1426,7 +1267,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(sessionKey);
|
const cs = this.clientSessions.get(client.id);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
const pending = cs.pendingToolCalls.get(event.toolCallId);
|
const pending = cs.pendingToolCalls.get(event.toolCallId);
|
||||||
cs.toolCalls.push({
|
cs.toolCalls.push({
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
|
||||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: AUTH,
|
|
||||||
useValue: {
|
|
||||||
api: {
|
|
||||||
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
|
|
||||||
headers.get('cookie') === 'session=trusted'
|
|
||||||
? { user: { id: 'operator-1', tenantId: 'tenant-1' }, session: { id: 'session-1' } }
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AuthGuard,
|
|
||||||
],
|
|
||||||
exports: [AUTH, AuthGuard],
|
|
||||||
})
|
|
||||||
class AuthenticatedRequestModule {}
|
|
||||||
|
|
||||||
describe('InteractionCoordinationController route aliases', (): void => {
|
|
||||||
let app: NestFastifyApplication | undefined;
|
|
||||||
const coordination = {
|
|
||||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
|
||||||
observe: vi.fn(async () => ({ status: 'running' })),
|
|
||||||
result: vi.fn(async () => ({ status: 'completed' })),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeAll(async (): Promise<void> => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [AuthenticatedRequestModule],
|
|
||||||
controllers: [InteractionCoordinationController],
|
|
||||||
providers: [{ provide: InteractionCoordinationService, useValue: coordination }],
|
|
||||||
}).compile();
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
afterAll(async (): Promise<void> => app?.close());
|
|
||||||
|
|
||||||
it('routes handoff, observe, and result through the same AuthGuard-protected service for both prefixes', async (): Promise<void> => {
|
|
||||||
if (!app) throw new Error('test app was not initialized');
|
|
||||||
for (const prefix of ['/api/coord/interaction', '/api/coord/mos']) {
|
|
||||||
const headers = { cookie: 'session=trusted', 'x-correlation-id': `corr-${prefix}` };
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `${prefix}/handoff`,
|
|
||||||
headers,
|
|
||||||
payload: { idempotencyKey: `key-${prefix}`, summary: 'handoff' },
|
|
||||||
})
|
|
||||||
).statusCode,
|
|
||||||
).toBe(201);
|
|
||||||
expect(
|
|
||||||
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/observe`, headers }))
|
|
||||||
.statusCode,
|
|
||||||
).toBe(200);
|
|
||||||
expect(
|
|
||||||
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/result`, headers }))
|
|
||||||
.statusCode,
|
|
||||||
).toBe(200);
|
|
||||||
}
|
|
||||||
expect(coordination.handoff).toHaveBeenCalledTimes(2);
|
|
||||||
expect(coordination.observe).toHaveBeenCalledTimes(2);
|
|
||||||
expect(coordination.result).toHaveBeenCalledTimes(2);
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/coord/interaction/handoff',
|
|
||||||
payload: { idempotencyKey: 'denied', summary: 'x' },
|
|
||||||
})
|
|
||||||
).statusCode,
|
|
||||||
).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -24,7 +24,6 @@ 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>();
|
||||||
|
|
||||||
@@ -34,14 +33,12 @@ 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: {
|
||||||
@@ -144,7 +141,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: 'Nova:discord:channel-001',
|
conversationId: 'discord-channel-001',
|
||||||
content: 'hello Tess',
|
content: 'hello Tess',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -156,7 +153,6 @@ 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' },
|
||||||
@@ -174,7 +170,6 @@ 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' } },
|
||||||
@@ -312,12 +307,27 @@ describe('Discord ingress security', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects approval when the durable session targets a different logical agent', async () => {
|
it.each([
|
||||||
configureDiscordEnv();
|
[
|
||||||
const { gateway, client, durable } = discordGateway('admin');
|
'binding',
|
||||||
|
() => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Other';
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'durable session',
|
||||||
|
(durable: { getSnapshot: ReturnType<typeof vi.fn> }) => {
|
||||||
durable.getSnapshot.mockResolvedValueOnce({
|
durable.getSnapshot.mockResolvedValueOnce({
|
||||||
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
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,
|
||||||
@@ -330,28 +340,8 @@ describe('Discord ingress security', () => {
|
|||||||
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();
|
||||||
@@ -408,267 +398,6 @@ 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({
|
||||||
@@ -681,7 +410,6 @@ 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' } },
|
||||||
|
|||||||
@@ -61,16 +61,6 @@ 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'];
|
||||||
@@ -92,10 +82,6 @@ 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'),
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
||||||
**Phase:** Execution (workstream W1 in planning-complete state)
|
**Phase:** Execution (workstream W1 in planning-complete state)
|
||||||
**Current Workstream:** W1 — Federation v1
|
**Current Workstream:** W1 — Federation v1
|
||||||
**Progress:** 0 / 3 declared workstreams complete (more workstreams will be declared as scope is refined)
|
**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined)
|
||||||
**Status:** active (continuous since 2026-03-13)
|
**Status:** active (continuous since 2026-03-13)
|
||||||
**Last Updated:** 2026-07-14 (W3 Native Kanban/SOT canon independently approved under issue #751)
|
**Last Updated:** 2026-04-19 (manifest authored at the rollup level; install-ux-v2 archived; W1 federation planning landed via PR #468)
|
||||||
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
||||||
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
||||||
|
|
||||||
@@ -68,11 +68,10 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
|||||||
## Workstreams
|
## Workstreams
|
||||||
|
|
||||||
| # | ID | Name | Status | Manifest | Notes |
|
| # | ID | Name | Status | Manifest | Notes |
|
||||||
| --- | ---- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
||||||
| W3 | KBN | Native Kanban and canonical task SOT | planning-complete | [docs/native-kanban-sot/MISSION-MANIFEST.md](./native-kanban-sot/MISSION-MANIFEST.md) | P0–P3; issue #751; implementation held until canon merge |
|
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||||
| W4+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
|
||||||
|
|
||||||
### Likely Additional Workstreams (Not Yet Declared)
|
### Likely Additional Workstreams (Not Yet Declared)
|
||||||
|
|
||||||
|
|||||||
131
docs/PRD.md
131
docs/PRD.md
@@ -79,52 +79,6 @@ 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` | M1–M5 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
|
||||||
@@ -221,69 +175,6 @@ 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
|
||||||
@@ -638,8 +529,7 @@ 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)
|
||||||
- Routes authorized untagged messages in-channel; mentions create threads (or reuse the same message's attached thread) for multi-turn topics
|
- Supports mention-based activation, thread management for multi-turn
|
||||||
- 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
|
||||||
|
|
||||||
@@ -812,12 +702,10 @@ Telegram remote control channel.
|
|||||||
|
|
||||||
### FR-9: Remote Control — Discord
|
### FR-9: Remote Control — Discord
|
||||||
|
|
||||||
- Discord bot that connects to the gateway through a transport-neutral channel adapter contract
|
- Discord bot that connects to the gateway
|
||||||
- Authorized messages in configured agent-bound channels work without a mention and respond in-channel
|
- Mention-based activation in channels
|
||||||
- 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)
|
||||||
@@ -1001,13 +889,10 @@ Telegram remote control channel.
|
|||||||
|
|
||||||
### AC-3: Discord Remote Control
|
### AC-3: Discord Remote Control
|
||||||
|
|
||||||
- [ ] Discord bot connects through the harness-neutral channel contract
|
- [ ] Discord bot connects and responds to mentions
|
||||||
- [ ] Authorized untagged channel messages route through the gateway and respond in-channel
|
- [ ] Messages route through gateway to agent pool
|
||||||
- [ ] 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)
|
||||||
- [ ] Unauthorized guilds, channels, users, pairings, and roles create no thread and dispatch no message
|
- [ ] Thread creation for multi-turn conversations
|
||||||
|
|
||||||
### AC-4: Gateway Orchestration
|
### AC-4: Gateway Orchestration
|
||||||
|
|
||||||
@@ -1206,7 +1091,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** — 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.
|
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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0–P3 requirements and acceptance criteria.
|
|
||||||
- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order.
|
|
||||||
- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model.
|
|
||||||
- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries.
|
|
||||||
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
|
|
||||||
- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001–016 findings that blocked the first draft.
|
|
||||||
- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict.
|
|
||||||
- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review.
|
|
||||||
|
|
||||||
## Tess interaction agent
|
|
||||||
|
|
||||||
### Operator guides
|
|
||||||
|
|
||||||
- [User guide](tess/USER-GUIDE.md) — authorized session, attach, send, stop, and handoff workflows.
|
|
||||||
- [Admin guide](tess/ADMIN-GUIDE.md) — deployment configuration, policy, and approval controls.
|
|
||||||
- [Developer guide](tess/DEVELOPER-GUIDE.md) — provider contracts, scope boundaries, and test workflow.
|
|
||||||
- [Plugin guide](tess/PLUGIN-GUIDE.md) — adapter, redaction, and identity-as-data requirements.
|
|
||||||
- [Operations guide](tess/OPERATIONS-GUIDE.md) — readiness, recovery, and incident-safe procedures.
|
|
||||||
|
|
||||||
### Architecture and security
|
|
||||||
|
|
||||||
- [Architecture](tess/ARCHITECTURE.md)
|
|
||||||
- [Threat model](tess/THREAT-MODEL.md)
|
|
||||||
- [Mos coordination boundary](tess/MOS-COORDINATION.md)
|
|
||||||
- [Hermes runtime adapter design](tess/hermes-runtime-adapter-design.md)
|
|
||||||
- [Operator plugin sketch](tess/M4-003-OPERATOR-PLUGIN-SKETCH.md)
|
|
||||||
|
|
||||||
### API contract
|
|
||||||
|
|
||||||
- [Tess OpenAPI contract](openapi-tess.yaml)
|
|
||||||
|
|
||||||
### Migration and qualification
|
|
||||||
|
|
||||||
- [Migration inventory](tess/M5-MIGRATION-INVENTORY.md)
|
|
||||||
- [Cutover procedure](tess/M5-MIGRATION-CUTOVER.md)
|
|
||||||
- [Rollback procedure](tess/M5-MIGRATION-ROLLBACK.md)
|
|
||||||
- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md)
|
|
||||||
- [Verification matrix](tess/VERIFICATION-MATRIX.md)
|
|
||||||
- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md)
|
|
||||||
- [Independent Option 2 runtime-portability qualification (2026-07-14)](tess/qualification/2026-07-14-option2-runtime-portability.md)
|
|
||||||
|
|
||||||
## Runtime-neutral Mos portability
|
|
||||||
|
|
||||||
- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies.
|
|
||||||
- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754)
|
|
||||||
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)
|
|
||||||
@@ -15,11 +15,9 @@
|
|||||||
## 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; M2–M7 deferred to mission planning |
|
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 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 |
|
|
||||||
| 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
|
||||||
|
|
||||||
@@ -43,30 +41,6 @@ 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) — M0–M5 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 M0–M5 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).
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
# ADR: Optional AI egress gateways for runtime-neutral Mos
|
|
||||||
|
|
||||||
**Status:** Proposed for controlled prototypes; not approved as Mosaic core
|
|
||||||
|
|
||||||
**Date:** 2026-07-14
|
|
||||||
|
|
||||||
**Issues:** #754, #755
|
|
||||||
|
|
||||||
**Decision owner:** Mosaic Gateway / provider-adapter architecture
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The emergency Mos continuity path kept Claude Code as the harness and translated Anthropic Messages traffic to Codex OAuth through a small localhost proxy. That preserved the existing Claude Discord plugin and transcript, but exposed two architectural facts:
|
|
||||||
|
|
||||||
1. Harness identity, channel entitlement, provider credentials, and inference transport are separate concerns.
|
|
||||||
2. A generic AI gateway can improve provider routing, budgets, and observability, but must not become Mosaic's identity, authorization, tenant, or orchestration boundary.
|
|
||||||
|
|
||||||
The Tess qualification report also found that current provider rebinding is not identity-continuous failover. Mosaic still needs a logical agent identity, durable connector lease/fencing, canonical handoff/checkpoint, exactly-once receipts, concrete harness adapters, and cross-harness rollback E2E.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Mosaic MAY support LiteLLM, Bifrost, the purpose-built Claude/Codex proxy, or future gateways as optional egress implementations behind `IProviderAdapter` / `AgentRuntimeProvider`.
|
|
||||||
|
|
||||||
Mosaic Gateway remains authoritative for:
|
|
||||||
|
|
||||||
- authenticated actor and tenant identity;
|
|
||||||
- logical agent identity and connector binding;
|
|
||||||
- authorization, approval, and policy;
|
|
||||||
- lease epoch and stale-holder fencing;
|
|
||||||
- audit correlation and redaction;
|
|
||||||
- canonical handoff/checkpoint state;
|
|
||||||
- idempotency and side-effect receipts.
|
|
||||||
|
|
||||||
An egress gateway MUST NOT:
|
|
||||||
|
|
||||||
- receive channel ingress directly;
|
|
||||||
- authorize tools or connector ownership;
|
|
||||||
- define Mosaic tenant or agent identity;
|
|
||||||
- persist raw Mosaic handoffs or channel credentials;
|
|
||||||
- bypass adapter capability negotiation;
|
|
||||||
- silently fail over when policy, lease, or provider health is uncertain.
|
|
||||||
|
|
||||||
Allowed topology:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Discord / Matrix / CLI / web
|
|
||||||
↓
|
|
||||||
Mosaic Gateway: identity, authz, lease/fence, approvals, audit
|
|
||||||
↓
|
|
||||||
IProviderAdapter / AgentRuntimeProvider
|
|
||||||
↓
|
|
||||||
optional egress gateway
|
|
||||||
↓
|
|
||||||
upstream provider or subscription-backed OAuth session
|
|
||||||
```
|
|
||||||
|
|
||||||
## Candidate assessment
|
|
||||||
|
|
||||||
### Purpose-built `raine/claude-code-proxy`
|
|
||||||
|
|
||||||
**Disposition:** Approved only for the verified emergency localhost bridge.
|
|
||||||
|
|
||||||
Strengths:
|
|
||||||
|
|
||||||
- explicit Codex device OAuth flow;
|
|
||||||
- small operational surface;
|
|
||||||
- Anthropic Messages translation suitable for Claude Code;
|
|
||||||
- model and reasoning-effort enforcement;
|
|
||||||
- straightforward loopback systemd supervision and rollback.
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
|
|
||||||
- not a Mosaic multi-tenant control plane;
|
|
||||||
- Claude built-in channels still depend on Claude subscription entitlement and feature lookup;
|
|
||||||
- model aliases can obscure the upstream model unless proxy policy/logs are treated as evidence;
|
|
||||||
- no replacement for connector leasing, canonical handoff, or exactly-once effects.
|
|
||||||
|
|
||||||
### LiteLLM
|
|
||||||
|
|
||||||
**Disposition:** Candidate for a formal adapter-only prototype and terms/security review.
|
|
||||||
|
|
||||||
Current documentation states that ChatGPT subscription access is available through an OAuth device-code flow. LiteLLM also provides broad provider routing, virtual keys, budgets, observability, and OpenAI/Anthropic-compatible surfaces.
|
|
||||||
|
|
||||||
Required prototype gates:
|
|
||||||
|
|
||||||
- verify the exact ChatGPT subscription OAuth flow and supported models against current provider terms;
|
|
||||||
- document token location, encryption, revocation, refresh, scope, and incident response;
|
|
||||||
- prove tenant isolation and prevent virtual keys from becoming Mosaic principals;
|
|
||||||
- verify streaming, tool calls, reasoning controls, cancellation, and idempotency metadata;
|
|
||||||
- fail closed instead of selecting an unhealthy provider merely to return a result;
|
|
||||||
- demonstrate that Mosaic audit correlation survives gateway retries/failover;
|
|
||||||
- keep channel ingress and connector credentials outside LiteLLM.
|
|
||||||
|
|
||||||
Source references:
|
|
||||||
|
|
||||||
- [LiteLLM ChatGPT subscription provider](https://docs.litellm.ai/docs/providers/chatgpt)
|
|
||||||
- [LiteLLM providers](https://docs.litellm.ai/docs/providers)
|
|
||||||
|
|
||||||
### Bifrost
|
|
||||||
|
|
||||||
**Disposition:** Candidate for governance/routing research; subscription OAuth compatibility unverified.
|
|
||||||
|
|
||||||
Useful concepts include virtual keys, budgets, rate limits, weighted load balancing, and automatic provider failover. Those features may inform Mosaic egress policy, but Bifrost virtual keys are downstream credentials—not Mosaic actors or tenants.
|
|
||||||
|
|
||||||
Required prototype gates:
|
|
||||||
|
|
||||||
- verify Codex/ChatGPT subscription OAuth rather than assuming API-key compatibility;
|
|
||||||
- map budgets and virtual keys to server-derived Mosaic tenants without duplicating authority;
|
|
||||||
- prove failover does not violate connector lease, approval, or exactly-once semantics;
|
|
||||||
- ensure request/response logs are redacted before persistence;
|
|
||||||
- disable or constrain automatic failover when policy or side-effect state is ambiguous.
|
|
||||||
|
|
||||||
Source references:
|
|
||||||
|
|
||||||
- [Bifrost overview](https://docs.getbifrost.ai/overview)
|
|
||||||
- [Bifrost repository](https://github.com/maximhq/bifrost)
|
|
||||||
|
|
||||||
### `teremterem/claude-code-gpt-5-codex`
|
|
||||||
|
|
||||||
**Disposition:** Not selected as the emergency implementation; useful as a historical LiteLLM recipe.
|
|
||||||
|
|
||||||
The reviewed repository uses `OPENAI_API_KEY`, tells previously authenticated Claude users to log out, and documents a Claude Web Search schema incompatibility. Logging Claude out conflicts with the channel-entitlement requirement observed in the live Mos cutover. The repository therefore does not, as provided, satisfy subscription-OAuth plus built-in-channel continuity.
|
|
||||||
|
|
||||||
Source references:
|
|
||||||
|
|
||||||
- [Repository](https://github.com/teremterem/claude-code-gpt-5-codex)
|
|
||||||
- [Environment template](https://github.com/teremterem/claude-code-gpt-5-codex/blob/main/.env.template)
|
|
||||||
|
|
||||||
## Security consequences
|
|
||||||
|
|
||||||
- Subscription OAuth grants are high-value credentials and require the same lifecycle controls as service credentials.
|
|
||||||
- Downstream virtual keys reduce provider-key exposure but do not establish user, tenant, or agent authority.
|
|
||||||
- Automatic retry/failover can duplicate tool or external side effects unless Mosaic owns operation IDs and receipts.
|
|
||||||
- Gateway telemetry can contain prompts, tool schemas, and model output; redaction and retention policy must apply before persistence.
|
|
||||||
- A localhost unauthenticated translation endpoint must remain loopback-only and process-isolated.
|
|
||||||
|
|
||||||
## Acceptance before production use
|
|
||||||
|
|
||||||
1. Threat model and provider-terms review approved.
|
|
||||||
2. Credential lifecycle and revocation drill documented and exercised.
|
|
||||||
3. Adapter contract tests pass for streaming, tools, cancellation, reasoning policy, errors, and audit correlation.
|
|
||||||
4. Tenant-bound authorization remains entirely in Mosaic Gateway.
|
|
||||||
5. Failure injection proves no duplicate side effects across retries or provider failover.
|
|
||||||
6. Rollback to the prior provider path is exercised.
|
|
||||||
7. Independent code and security reviews approve the exact deployed revision.
|
|
||||||
|
|
||||||
## Follow-up
|
|
||||||
|
|
||||||
- #754 owns cross-harness logical identity, checkpoint, receipt, adapter, and failover work.
|
|
||||||
- #755 / PR #757 implements the first logical identity and connector lease/fencing boundary.
|
|
||||||
- A later issue should prototype LiteLLM and Bifrost behind the provider adapter after #755 is merged and independently qualified.
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Channel Protocol Architecture
|
# Channel Protocol Architecture
|
||||||
|
|
||||||
**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative
|
**Status:** Draft
|
||||||
**Authors:** Mosaic Core Team
|
**Authors:** Mosaic Core Team
|
||||||
**Last Updated:** 2026-07-14
|
**Last Updated:** 2026-03-22
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,80 +11,93 @@
|
|||||||
|
|
||||||
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 implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups:
|
The protocol consists of two main contracts:
|
||||||
|
|
||||||
1. `OfficialChannelAdapter` — transport lifecycle and connection health.
|
1. `IChannelAdapter` — the interface each channel driver must implement.
|
||||||
2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data.
|
2. `ChannelMessage` — the canonical message format that flows through the system.
|
||||||
3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address.
|
|
||||||
4. `ChannelResponseTargetDto` — channel/thread destination for replies.
|
|
||||||
|
|
||||||
All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter.
|
All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M7-001: OfficialChannelAdapter Interface
|
## M7-001: IChannelAdapter Interface
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface OfficialChannelAdapter {
|
interface IChannelAdapter {
|
||||||
/** 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>;
|
/**
|
||||||
/** Gracefully close connections and release resources. */
|
* Establish a connection to the external channel backend.
|
||||||
stop(): Promise<void>;
|
* Called once at application startup. Must be idempotent (safe to call
|
||||||
/** Best-effort health; ordinary disconnection is a result, not an exception. */
|
* when already connected).
|
||||||
health(): Promise<{
|
*/
|
||||||
status: 'connected' | 'degraded' | 'disconnected';
|
connect(): Promise<void>;
|
||||||
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 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.
|
Adapters are registered with the `ChannelRegistry` service at startup. The registry calls `connect()` on each adapter and monitors `health()` on a configurable interval (default: 30 s).
|
||||||
|
|
||||||
```
|
```
|
||||||
ChannelRegistry
|
ChannelRegistry
|
||||||
└── register(adapter: OfficialChannelAdapter): void
|
└── register(adapter: IChannelAdapter): void
|
||||||
└── getAdapter(name: string): OfficialChannelAdapter | null
|
└── getAdapter(name: string): IChannelAdapter | null
|
||||||
└── listAdapters(): OfficialChannelAdapter[]
|
└── listAdapters(): IChannelAdapter[]
|
||||||
└── healthAll(): Promise<Record<string, AdapterHealth>>
|
└── healthAll(): Promise<Record<string, AdapterHealth>>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M7-002: ChannelMessageDto Protocol
|
## M7-002: ChannelMessage Protocol
|
||||||
|
|
||||||
### Canonical Message Format
|
### Canonical Message Format
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ChannelMessageDto {
|
interface ChannelMessage {
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -97,7 +110,6 @@ interface ChannelMessageDto {
|
|||||||
* 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,7 +119,7 @@ interface ChannelMessageDto {
|
|||||||
senderId: string;
|
senderId: string;
|
||||||
|
|
||||||
/** Sender classification. */
|
/** Sender classification. */
|
||||||
senderKind: 'user' | 'agent' | 'system';
|
senderType: 'user' | 'agent' | 'system';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Textual content of the message.
|
* Textual content of the message.
|
||||||
@@ -124,7 +136,7 @@ interface ChannelMessageDto {
|
|||||||
* - "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
|
||||||
*/
|
*/
|
||||||
contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file';
|
contentType: 'text' | 'markdown' | 'code' | 'image' | 'file';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arbitrary key-value metadata for channel-specific extension fields.
|
* Arbitrary key-value metadata for channel-specific extension fields.
|
||||||
@@ -132,7 +144,7 @@ interface ChannelMessageDto {
|
|||||||
* 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: Readonly<Record<string, ChannelMetadataValue>>;
|
metadata: Record<string, unknown>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional thread or reply-chain identifier.
|
* Optional thread or reply-chain identifier.
|
||||||
@@ -151,21 +163,18 @@ interface ChannelMessageDto {
|
|||||||
* 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?: readonly ChannelAttachmentDto[];
|
attachments?: ChannelAttachment[];
|
||||||
|
|
||||||
/** ISO-8601 wall-clock timestamp when the message was sent/received. */
|
/** Wall-clock timestamp when the message was sent/received. */
|
||||||
timestamp: string;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChannelAttachmentDto {
|
interface ChannelAttachment {
|
||||||
/** Channel-native attachment identifier. */
|
/** Filename or identifier. */
|
||||||
id: string;
|
|
||||||
|
|
||||||
/** Filename or display name. */
|
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
/** MIME type when supplied by the channel. */
|
/** MIME type (e.g. "image/png", "application/pdf"). */
|
||||||
mimeType: string | null;
|
mimeType: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL pointing to the attachment, OR a `data:` URI with base64 payload.
|
* URL pointing to the attachment, OR a `data:` URI with base64 payload.
|
||||||
@@ -183,18 +192,18 @@ interface ChannelAttachmentDto {
|
|||||||
|
|
||||||
## Channel Translation Reference
|
## Channel Translation Reference
|
||||||
|
|
||||||
The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`.
|
The following sections document how each supported channel maps its native message format to and from `ChannelMessage`.
|
||||||
|
|
||||||
### Matrix
|
### Matrix
|
||||||
|
|
||||||
| ChannelMessageDto field | Matrix equivalent |
|
| ChannelMessage 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`) |
|
||||||
| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
|
| `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
|
||||||
| `content` | `event.content.body` |
|
| `content` | `event.content.body` |
|
||||||
| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
|
| `contentType` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
|
||||||
| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` |
|
| `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 |
|
||||||
@@ -207,34 +216,21 @@ The following sections document how each supported channel maps its native messa
|
|||||||
|
|
||||||
### Discord
|
### Discord
|
||||||
|
|
||||||
| ChannelMessageDto field | Discord equivalent |
|
| ChannelMessage 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) |
|
||||||
| `senderKind` | `"user"` for human members; `"agent"` for bot messages |
|
| `senderType` | `"user"` for human members; `"agent"` for bot messages |
|
||||||
| `content` | `message.content` |
|
| `content` | `message.content` |
|
||||||
| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) |
|
| `contentType` | `"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 `ChannelAttachmentDto` |
|
| `attachments` | `message.attachments` mapped to `ChannelAttachment` |
|
||||||
| `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 `contentKind = "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 `contentType = "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
|
||||||
|
|
||||||
@@ -244,21 +240,21 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket
|
|||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|
||||||
| ChannelMessageDto field | Telegram equivalent |
|
| ChannelMessage 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) |
|
||||||
| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages |
|
| `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages |
|
||||||
| `content` | `message.text` or `message.caption` |
|
| `content` | `message.text` or `message.caption` |
|
||||||
| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
|
| `contentType` | `"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 `ChannelAttachmentDto` |
|
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` |
|
||||||
| `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 `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
|
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -266,14 +262,14 @@ 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.
|
||||||
|
|
||||||
| ChannelMessageDto field | TUI equivalent |
|
| ChannelMessage 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` |
|
||||||
| `senderKind` | `"user"` for human input; `"agent"` for agent replies |
|
| `senderType` | `"user"` for human input; `"agent"` for agent replies |
|
||||||
| `content` | Raw text from stdin / agent output |
|
| `content` | Raw text from stdin / agent output |
|
||||||
| `contentKind` | `"text"` for input; `"markdown"` for agent responses |
|
| `contentType` | `"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 |
|
||||||
@@ -288,14 +284,14 @@ 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`).
|
||||||
|
|
||||||
| ChannelMessageDto field | WebUI equivalent |
|
| ChannelMessage 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` |
|
||||||
| `senderKind` | `"user"` for browser input; `"agent"` for agent responses |
|
| `senderType` | `"user"` for browser input; `"agent"` for agent responses |
|
||||||
| `content` | Message text from the input field |
|
| `content` | Message text from the input field |
|
||||||
| `contentKind` | `"text"` or `"markdown"` |
|
| `contentType` | `"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 |
|
||||||
@@ -308,7 +304,7 @@ The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel prot
|
|||||||
|
|
||||||
## Identity Mapping
|
## Identity Mapping
|
||||||
|
|
||||||
Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity.
|
`mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts.
|
||||||
|
|
||||||
The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context).
|
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).
|
||||||
|
|
||||||
@@ -327,8 +323,8 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
|
|||||||
|
|
||||||
## Error Handling Conventions
|
## Error Handling Conventions
|
||||||
|
|
||||||
- `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready.
|
- `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s).
|
||||||
- `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.
|
- `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.
|
||||||
- `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.
|
||||||
|
|
||||||
@@ -336,7 +332,7 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
|
|||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
The `ChannelMessageDto` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
|
The `ChannelMessage` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
|
||||||
|
|
||||||
Current version: **1.0.0**
|
Current version: **1.0.0**
|
||||||
|
|
||||||
@@ -477,7 +473,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 `ChannelMessageDto` and delivers it to `ConversationService`.
|
2. The surface's adapter normalizes the message to `ChannelMessage` 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.
|
||||||
@@ -565,7 +561,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 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).
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -737,7 +733,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 `ChannelMessageDto` 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 `ChannelMessage` record unless the Mosaic retention policy also covers it.
|
||||||
|
|
||||||
Default retention values:
|
Default retention values:
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
# 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 M1–M5, and requires key-name/hash-only quarantine diagnostics.
|
|
||||||
- [ ] `docs/PRD.md` identifies the M1–M5 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 M0–M5 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 M1–M5
|
|
||||||
|
|
||||||
| 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 | M2–M3 |
|
|
||||||
| `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 | M1–M4 |
|
|
||||||
| `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.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -294,7 +294,7 @@ 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 |
|
||||||
@@ -303,9 +303,6 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
| `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 |
|
||||||
| `DISCORD_INTERACTION_BINDINGS` | Required JSON bindings from guild/channel to logical agent and paired Discord users with `viewer`, `operator`, or `admin` roles |
|
|
||||||
| `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_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
||||||
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
||||||
|
|
||||||
@@ -313,44 +310,7 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
|
|
||||||
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 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.
|
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.
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
# Documentation Completion Checklist — Native Kanban/SOT Canon
|
|
||||||
|
|
||||||
**Tracking:** Mosaic Stack issue #751
|
|
||||||
**Scope:** Requirements and contract publication only; runtime implementation follows in separate slices.
|
|
||||||
|
|
||||||
## Required artifacts
|
|
||||||
|
|
||||||
- [x] Project `docs/PRD.md` exists; the workstream requirements refine its task/project-management scope.
|
|
||||||
- [x] Canonical workstream requirements published at `docs/requirements/native-kanban-sot.md`.
|
|
||||||
- [x] Mission manifest, task decomposition, frozen shared contract, and typed contract declarations included.
|
|
||||||
- [x] `docs/SITEMAP.md` updated.
|
|
||||||
- [x] Independent initial review and final GO report stored under `docs/reports/native-kanban-sot/`.
|
|
||||||
- [x] Task scratchpad stored under `docs/scratchpads/`.
|
|
||||||
- [ ] User/Admin/Developer guides — N/A for canon-only publication; required in implementation slices that change behavior or operations.
|
|
||||||
- [ ] OpenAPI and endpoint index — N/A until KBN-105 freezes implementation-ready endpoint contracts.
|
|
||||||
|
|
||||||
## Structural and root hygiene
|
|
||||||
|
|
||||||
- [x] Canonical requirements are under `docs/requirements/`.
|
|
||||||
- [x] Workstream artifacts are under `docs/native-kanban-sot/`.
|
|
||||||
- [x] Review reports are under `docs/reports/native-kanban-sot/`.
|
|
||||||
- [x] No new unscoped document was added to the `docs/` root.
|
|
||||||
- [x] Root mission/task rollups link to the workstream.
|
|
||||||
|
|
||||||
## Review gate
|
|
||||||
|
|
||||||
- [x] Author and independent reviewer are different agents.
|
|
||||||
- [x] KCR-001–016 closure was independently verified.
|
|
||||||
- [x] Ultron final gate returned GO with zero BLOCKER/HIGH findings.
|
|
||||||
- [x] Formatter, lint, typecheck, strict contract TypeScript, link, scope, and invariant publication validation passed in the current Stack toolchain.
|
|
||||||
- [ ] PR review, CI, squash merge, and issue closure remain required before publication completion.
|
|
||||||
|
|
||||||
## Publishing
|
|
||||||
|
|
||||||
- [x] Canonical source remains in-repository.
|
|
||||||
- [x] No external publishing platform is required for this internal architecture contract.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Native Kanban/SOT Canon
|
|
||||||
|
|
||||||
**Status:** KCR-001–016 independently cleared; canonical publication is in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
|
||||||
**Date:** 2026-07-14
|
|
||||||
**Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied.
|
|
||||||
|
|
||||||
## Artifacts
|
|
||||||
|
|
||||||
| Artifact | Purpose |
|
|
||||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0–P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria |
|
|
||||||
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership |
|
|
||||||
| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0–P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization |
|
|
||||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
|
||||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
|
||||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
|
||||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
|
||||||
| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults |
|
|
||||||
| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations |
|
|
||||||
| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals |
|
|
||||||
| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001–016 findings that blocked the first draft |
|
|
||||||
| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
|
|
||||||
| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
|
|
||||||
|
|
||||||
## Recommended USC lane partition
|
|
||||||
|
|
||||||
| Lane | Natural seam | Exclusive ownership |
|
|
||||||
| ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **coder2** | Schema + migrations + recovery slice | Unified Drizzle schema, migration SQL/meta/journal/tests, then recovery parser/mechanism/runbook files |
|
|
||||||
| **coder3** | Domain + Gateway + MCP server | Workspace-safe repositories, DTOs/controllers/services, exact `apps/gateway/src/mcp/**` files, health proof, proposals, Coordinator persistence adapter |
|
|
||||||
| **coder4** | Pure Coordinator + tooling | `packages/coord` mechanical engine, CLI/MCP consumers, generated projection, one-way importer and cutover tooling; lane-serialized internally |
|
|
||||||
| **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI |
|
|
||||||
| **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority |
|
|
||||||
|
|
||||||
The safe order is KBN-010 → KBN-100 → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files.
|
|
||||||
|
|
||||||
## Recovery defaults
|
|
||||||
|
|
||||||
| Tier | RPO / RTO | WAL / PITR | Base backup | Restore / break-glass | Off-cluster |
|
|
||||||
| -------------- | ------------ | ------------------- | ----------- | ----------------------- | ----------------------------------------------- |
|
|
||||||
| Lite | 24h / 24h | disabled / disabled | daily | quarterly / annual | encrypted separate target |
|
|
||||||
| Standard | 1h / 8h | q15m / 14d | daily | quarterly / semiannual | encrypted separate object storage |
|
|
||||||
| High-assurance | **15m / 4h** | **q5m / 35d** | **daily** | **monthly / quarterly** | **encrypted base+WAL, separate failure domain** |
|
|
||||||
|
|
||||||
These knobs affect recovery posture only. PostgreSQL remains the sole writable SOT in every tier. Fail-closed writes, generated-file non-authority, attributable post-recovery proposals, non-LLM Coordinator limits, and Certifier final-gate/no-merge authority are fixed for every tier.
|
|
||||||
|
|
||||||
## Non-blocking implementation sub-decisions for Mos
|
|
||||||
|
|
||||||
The source plan and ratified seven decisions resolve all build-blocking product choices. The following implementation-local selections remain for the owning slices/Mos and must not weaken v1:
|
|
||||||
|
|
||||||
1. Exact PostgreSQL write-health probe SQL and bounded proof lifetime; authority and failures are frozen.
|
|
||||||
2. Dependency-cycle serialization mechanism (recursive CTE plus transaction/advisory lock or equivalent); required behavior is frozen.
|
|
||||||
3. Whether RLS lands in the first migration or immediately after the tested session-context pattern; workspace constraints/repository authorization are required from migration one.
|
|
||||||
4. Concrete off-cluster backup provider/bucket and selected production recovery tier; High-assurance minima are frozen if selected.
|
|
||||||
5. Cutover reconciliation thresholds and stabilization duration, to be owner-approved before P3 execution.
|
|
||||||
|
|
||||||
None authorizes a second writer, dual sync, LLM scheduling, Coordinator gate waiver/merge, or Certifier merge authority.
|
|
||||||
|
|
||||||
## Publication validation evidence
|
|
||||||
|
|
||||||
- Concrete TypeScript contracts are formatted with repository Prettier.
|
|
||||||
- All four contracts pass strict TypeScript no-emit checking against the current Stack Drizzle toolchain.
|
|
||||||
- Contract remediation and KCR-001–016 traceability are recorded in the issue scratchpad and linked review reports.
|
|
||||||
- Independent re-review returned GO with KCR-001–016 closed; implementation remains held until canon merge and the dependency-ordered KBN prerequisites complete.
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3
|
|
||||||
|
|
||||||
**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
|
||||||
**Date:** 2026-07-14
|
|
||||||
**Human decision owner:** Jason
|
|
||||||
**Orchestrator/publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage)
|
|
||||||
**Execution topology:** USC web1, partitioned across collision-free GPT coder2/3/4/5 lanes
|
|
||||||
**Canonical requirements:** [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md)
|
|
||||||
**Frozen integration contract:** `SHARED-CONTRACT.md` and `contracts/*.v1.ts`
|
|
||||||
|
|
||||||
## 1. Mission statement
|
|
||||||
|
|
||||||
Extend current `mosaicstack/stack` main into the sole native control plane for workspace-scoped project, mission, milestone, task, dependency, assignment, lease, approval, evidence, and audit state. First deliver a thin writable Kanban/List vertical slice; then add deterministic mechanical coordination and execute a one-way migration/cutover from jarvis-brain/Vikunja project/task stores.
|
|
||||||
|
|
||||||
Success means every user, agent, orchestrator, specialist, and UI sees and mutates the same PostgreSQL aggregate revisions through typed Gateway commands, with no writable fallback and no hidden second authority.
|
|
||||||
|
|
||||||
## 2. Scope boundaries
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
|
|
||||||
- Current Drizzle/PostgreSQL schema extension and migrations.
|
|
||||||
- Workspace tenancy and authorization from the first migration.
|
|
||||||
- Projects, missions, milestones, tasks, normalized tags, dependencies, assignments, durable execution/quarantine state, links, immutable artifacts/evidence joins, outage change proposals, events, approvals, leases, checkpoints, and transactional outbox.
|
|
||||||
- NestJS Gateway queries and explicit lifecycle commands.
|
|
||||||
- MCP/CLI agent surfaces and generated read-only projections.
|
|
||||||
- Thin writable Next.js Tasks Kanban/List, task detail, minimal Projects CRUD, filters, dependency readiness, ownership/lease separation, and audit timeline.
|
|
||||||
- Non-LLM Mechanical Coordinator eligibility, proposal, approval-policy, lease/fence, heartbeat, retry, expiry, quarantine, and restart recovery.
|
|
||||||
- Planning, Enhance, Coder, Review, SecReview, PR-Monitor, and Certifier role/gate representation.
|
|
||||||
- One-way shadow importer, reconciliation, write freeze, final delta, cutover, rollback package, and legacy read-only stabilization.
|
|
||||||
- Recovery-posture configuration and health-state/fail-closed contract.
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
|
|
||||||
- Greenfield services, Prisma runtime revival, or jarvis-brain flat files as runtime storage.
|
|
||||||
- Writable Markdown/JSON/Valkey/browser/provider fallback.
|
|
||||||
- Gitea issue/PR replacement or generic bidirectional provider sync.
|
|
||||||
- Calendar, email, GLPI cache, CRM, billing, time tracking, personal-brain migration.
|
|
||||||
- LLM scheduling or scope interpretation by the Coordinator.
|
|
||||||
- Autonomous gate waiver, certification, merge, release, deployment, or issue closure by Coordinator.
|
|
||||||
- Merge authority for Certifier.
|
|
||||||
- P4 full portfolio/mission designer and P5 fleet-scale policy unless separately released.
|
|
||||||
|
|
||||||
## 3. Fixed invariants
|
|
||||||
|
|
||||||
Every deployment MUST preserve all of the following:
|
|
||||||
|
|
||||||
1. PostgreSQL is the sole writable SOT.
|
|
||||||
2. Drizzle on current stack main is the only persistence foundation.
|
|
||||||
3. Mutations fail closed when DB write-health cannot be proven `healthy`.
|
|
||||||
4. No file, Valkey, browser, queue, provider, or human note becomes a fallback writer.
|
|
||||||
5. `TASKS.md`, `mission.json`, and every file export are generated, read-only, non-authoritative, and never import sources.
|
|
||||||
6. Human outage notes become attributable post-recovery proposals only.
|
|
||||||
7. Workspace is the hard tenant; Team is intra-workspace authorization.
|
|
||||||
8. Valkey is expendable; PostgreSQL owns state, leases, fencing, audit, and outbox.
|
|
||||||
9. Mechanical Coordinator is deterministic/non-LLM and cannot invent scope, waive gates, certify, or merge.
|
|
||||||
10. Certifier is the final independent quality gate and has no merge authority.
|
|
||||||
11. Mutations use idempotency and optimistic aggregate versions; worker commands also require a current fencing token.
|
|
||||||
12. Recovery tier changes only backup/recovery posture, never authority or gate semantics.
|
|
||||||
|
|
||||||
## 4. Configurable recovery posture
|
|
||||||
|
|
||||||
Deployments select Lite, Standard, or High-assurance defaults from [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md) and `contracts/recovery-posture.v1.ts`. Configurable fields are limited to:
|
|
||||||
|
|
||||||
- backup/base-backup cadence;
|
|
||||||
- RPO and RTO targets;
|
|
||||||
- PITR retention;
|
|
||||||
- WAL archive cadence;
|
|
||||||
- restore-test frequency;
|
|
||||||
- break-glass drill frequency;
|
|
||||||
- encrypted off-cluster storage.
|
|
||||||
|
|
||||||
High-assurance defaults are fixed reference values: RPO 15 minutes, RTO 4 hours, encrypted off-cluster WAL every 5 minutes with 35-day PITR, daily base backup, monthly restore test, and quarterly break-glass drill.
|
|
||||||
|
|
||||||
## 5. Canonical role map
|
|
||||||
|
|
||||||
```text
|
|
||||||
User
|
|
||||||
↓ objectives, constraints, ratified decisions
|
|
||||||
Interaction Layer
|
|
||||||
↓ workspace/project context; no scheduling authority
|
|
||||||
Portfolio Orchestrator
|
|
||||||
↓ approved mission, cross-project priority/capacity
|
|
||||||
Project Sub-Orchestrator
|
|
||||||
↓ decomposition, DAG, acceptance, release, routing policy, overrides
|
|
||||||
Gateway
|
|
||||||
↓ authenticated/authorized typed commands
|
|
||||||
Project/Task Domain Services
|
|
||||||
↓ transactional state + semantic event + outbox
|
|
||||||
Mechanical Coordinator
|
|
||||||
↓ deterministic eligibility/proposal/lease/fence/retry/quarantine
|
|
||||||
Specialists
|
|
||||||
Planning → Enhance → Coder → Review → conditional SecReview → remediation
|
|
||||||
↓ complete evidence bundle
|
|
||||||
Certifier
|
|
||||||
↓ final pass/reject/escalate; NO merge authority
|
|
||||||
Project Sub-Orchestrator / control plane
|
|
||||||
↓ merge authority after all gates
|
|
||||||
Post-merge validation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Authority table
|
|
||||||
|
|
||||||
| Role/layer | Owns | Explicitly cannot do |
|
|
||||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
||||||
| User | Objectives, constraints, Jason-owned decisions | Direct DB/file authority bypass |
|
|
||||||
| Interaction | Conversation and context resolution | Schedule, approve, lease, certify |
|
|
||||||
| Portfolio Orchestrator | Mission approval, cross-project priority/capacity/global holds | Implement or self-certify specialist work |
|
|
||||||
| Project Sub-Orchestrator | Task decomposition/DAG/acceptance, release to ready, routing policy, overrides, remediation, merge go-ahead | Bypass required independent gates |
|
|
||||||
| Gateway | Identity, tenancy, DTO validation, commands, state-machine enforcement | Accept file edits or client SQL as mutations |
|
|
||||||
| Domain services | Transactional business invariants, semantic events/outbox | Depend on Valkey/files for committed truth |
|
|
||||||
| Mechanical Coordinator | Eligibility, dependencies, proposal, approved routing, lease/fence, heartbeat, retry/quarantine | Invent/alter scope, waive gates, certify, merge |
|
|
||||||
| Specialists | Bounded planning/implementation/review artifacts under a task lease | Modify another lane's owned files or self-approve |
|
|
||||||
| Certifier | Final independent evidence/traceability/gate decision | Merge, close provider issue, release, waive policy |
|
|
||||||
|
|
||||||
## 6. Gate model
|
|
||||||
|
|
||||||
### Mandatory gates
|
|
||||||
|
|
||||||
1. Requirements/contract freeze before parallel implementation.
|
|
||||||
2. P0 schema/authority threat model and tenant isolation review.
|
|
||||||
3. Author and reviewer MUST be different principals/sessions.
|
|
||||||
4. Functional review validates requirements, endpoint registry, concurrency, and negative paths.
|
|
||||||
5. **Mandatory SecReview (`secrev`)** for any auth, authorization, tenant, service-token, secret, database schema/migration, data-integrity, import/cutover, audit, lease/fencing, recovery, or destructive-retirement surface.
|
|
||||||
6. Review findings enter bounded remediation owned by the implementation lane.
|
|
||||||
7. Raising reviewer re-verifies remediation.
|
|
||||||
8. Certifier performs the final independent evidence and traceability gate.
|
|
||||||
9. Merge authority remains with `mos-claude`/Project Sub-Orchestrator control plane after gates pass.
|
|
||||||
10. Post-merge CI and situational validation must be terminal green before closure.
|
|
||||||
|
|
||||||
### Gate outcomes
|
|
||||||
|
|
||||||
- **PASS:** evidence complete; next authority may proceed.
|
|
||||||
- **REJECT:** findings are explicit and route to remediation.
|
|
||||||
- **ESCALATE:** policy/owner decision required; no implicit waiver.
|
|
||||||
|
|
||||||
No role can transform a missing gate into a warning by changing status, editing a projection, or writing Valkey.
|
|
||||||
|
|
||||||
## 7. Slice ownership rules
|
|
||||||
|
|
||||||
1. USC web1 is the sole execution environment; coder2/3/4/5 are independent bounded lanes under Mos.
|
|
||||||
2. Every slice has one named file-tree owner and an explicit IN/OUT boundary in `TASKS.md`.
|
|
||||||
3. Two active slices MUST NOT edit the same source file, migration file, generated snapshot, lockfile, or API contract.
|
|
||||||
4. coder2 exclusively owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, migration journal/meta/tests, then its disjoint recovery-parser/runbook slice. All schema requests serialize through coder2.
|
|
||||||
5. Frozen `contracts/*.v1.ts` are read-only inputs during implementation. Contract changes require Mos approval, a version bump/amendment, and coordinated rebase before work resumes.
|
|
||||||
6. coder3 exclusively owns Gateway DTO/controllers/services and the enumerated `apps/gateway/src/mcp/**` server files. coder4 owns CLI/projection clients and never edits MCP server files. Web consumers use the exact KBN-105 endpoint/DTO freeze.
|
|
||||||
7. coder4 executes one lane order: CLI/projection → pure Coordinator → importer → cutover. The pure Coordinator under `packages/coord` does not load IDs or access DB, Gateway, Valkey, recovery I/O, or web files; coder3 owns the persistence/service adapter.
|
|
||||||
8. Migration/import tooling calls Gateway/migration-only approved ports and does not add a second database model.
|
|
||||||
9. Each lane commits only its owned files and reports any needed cross-slice change as a contract-change request instead of editing another lane's tree.
|
|
||||||
10. Cross-review is mandatory: no lane reviews its own changes. Recommended ring is coder2 ← coder5, coder3 ← coder2, coder4 ← coder3, coder5 ← coder4, followed by independent SecReview where triggered and Certifier final.
|
|
||||||
11. Integration-only edits are a separate serialized slice after component lanes are green; no opportunistic merge-conflict resolution may alter semantics.
|
|
||||||
|
|
||||||
## 8. Delivery phases and exit gates
|
|
||||||
|
|
||||||
### P0 — Canon and authority foundation
|
|
||||||
|
|
||||||
- Publish this canon, frozen schema/ports/health/recovery contracts, threat model, authorization matrix, exact endpoint/DTO registry, concrete current-main field-by-field migration map, and standards amendment.
|
|
||||||
- Build hold remains active until independent author≠reviewer re-review returns GO on health proof/failures, approval binding, fencing, tenant relationships, proposals, migration map, slice ordering/API freeze, recovery validation, and vocabulary alignment.
|
|
||||||
- Exit: no unresolved second writer or contract blocker, tenant boundary frozen, all seven decisions traceable, and independent re-review GO recorded.
|
|
||||||
|
|
||||||
### P1 — Thin native MVP
|
|
||||||
|
|
||||||
- Schema/migration, tenant-safe Gateway, CLI/MCP/projection, writable Kanban/List/Projects, dependencies/readiness/audit.
|
|
||||||
- Exit: same revision across web/CLI/MCP/projection; cross-workspace tests fail closed; generated files cannot mutate state.
|
|
||||||
|
|
||||||
### P2 — Mechanical coordination
|
|
||||||
|
|
||||||
- Agent/session registry, deterministic engine, approval queue, PostgreSQL leases/fencing/checkpoints/outbox, retry/quarantine, operations UI.
|
|
||||||
- Exit: one lease winner, stale tokens rejected, dependencies/approvals enforced, DB/Valkey fault semantics proven, Certifier gate has no merge authority.
|
|
||||||
|
|
||||||
### P3 — Shadow migration and cutover
|
|
||||||
|
|
||||||
- Importer, lineage, reconciliation, reviewer UI, write freeze, final delta, Gateway switch, legacy read-only, stabilization and rollback package.
|
|
||||||
- Exit: signed reconciliation, zero active legacy writers, scoped Gateway identities, imported backlog cannot dispatch accidentally.
|
|
||||||
|
|
||||||
## 9. Evidence required for mission closure
|
|
||||||
|
|
||||||
- Requirement-to-test/evidence matrix.
|
|
||||||
- Schema/migration and N-1 rolling-deploy proof.
|
|
||||||
- Cross-workspace API/repository/import/Coordinator negative tests.
|
|
||||||
- Health-state and fail-closed fault injection.
|
|
||||||
- Valkey-loss/outbox replay and Coordinator restart tests.
|
|
||||||
- Concurrent lease and stale fencing tests.
|
|
||||||
- Endpoint-registry alignment across web/CLI/MCP/Gateway.
|
|
||||||
- Accessible real-Gateway Kanban journeys.
|
|
||||||
- Generated projection tamper/no-import proof.
|
|
||||||
- One-way migration dry-run/apply/verify and field reconciliation.
|
|
||||||
- Author-independent functional review and required SecReview.
|
|
||||||
- Certifier final decision and evidence bundle.
|
|
||||||
- Merged main SHA, terminal green CI, closed linked task/issue, and post-merge situational validation under orchestrator ownership.
|
|
||||||
|
|
||||||
## 10. Change control
|
|
||||||
|
|
||||||
This manifest is derived from the ratified source plan. Any change to SOT authority, workspace tenancy, fixed statuses, Coordinator/Certifier authority, health-state semantics, schema v1, migration direction, or recovery-tier field set is a contract change. Contract changes require Jason/Mos authorization and cannot be inferred by an implementation lane.
|
|
||||||
|
|
||||||
No coder lane may start while the build hold is active. KBN-010 must complete before KBN-100; KBN-105 exact endpoint/DTO freeze must complete before any API consumer implementation.
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
# Native Kanban/SOT — Remediated Shared Contract v1
|
|
||||||
|
|
||||||
**Status:** INDEPENDENT REVIEW GO; freezes as v1 when issue #751 canon merges to `main`
|
|
||||||
**Version:** 1.0.0-rc.3
|
|
||||||
**Date:** 2026-07-14
|
|
||||||
**Change authority:** Mosaic control plane/Jason only
|
|
||||||
|
|
||||||
## 1. Authority
|
|
||||||
|
|
||||||
Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied.
|
|
||||||
|
|
||||||
## 2. Health proof and exact failures
|
|
||||||
|
|
||||||
`KanbanHealthResponseV1` is a discriminated union:
|
|
||||||
|
|
||||||
| State | read | write | Capability |
|
|
||||||
| -------------------- | ----: | ----: | --------------------------------------------------- |
|
|
||||||
| `healthy` | true | true | reads; public state still cannot authorize mutation |
|
|
||||||
| `read-only-degraded` | true | false | reads only |
|
|
||||||
| `write-unavailable` | false | false | diagnostics only |
|
|
||||||
|
|
||||||
Every response has `checkedAt`, `validUntil`, `policyRevision`; contradictory booleans fail validation.
|
|
||||||
|
|
||||||
For a mutation, Gateway opens the PostgreSQL transaction, executes the live write probe on that transaction/connection, mints the internal branded `PostgresWriteHealthProofV1`, and revalidates time/policy/transaction identity immediately before mutation. Public REST/MCP/CLI DTOs never accept health/proof fields. Valkey/caller assertions cannot mint proof. Pure Coordinator takes `KanbanEvaluationContextV1`; persistence takes `InternalKanbanMutationContextV1` or probes internally.
|
|
||||||
|
|
||||||
| Case | HTTP | Frozen result | Retry |
|
|
||||||
| ---------------------------- | --------------------------: | ------------------------------------------------------------------- | -------------------- |
|
|
||||||
| degraded write | 503 | `KANBAN_WRITE_HEALTH_UNPROVEN`, `read-only-degraded`, `not_applied` | false |
|
|
||||||
| write unavailable | 503 | `KANBAN_WRITE_UNAVAILABLE`, `write-unavailable`, `not_applied` | false |
|
|
||||||
| version conflict | 409 | `AGGREGATE_VERSION_CONFLICT`, actual version, `not_applied` | false |
|
|
||||||
| timeout/unreachable | timeout/502/504 | `retryable_transport_error`, `unknown` | same idempotency key |
|
|
||||||
| stale fence/session/approval | coordinator rejection union | `not_applied` | false |
|
|
||||||
|
|
||||||
Required negatives: contradictory state, expired/policy-mismatched/wrong-transaction proof, Valkey-only health, forged healthy, and exhaustive non-cross-mapping of 503 vs 502/504/timeout vs 409.
|
|
||||||
|
|
||||||
## 3. Canonical schema invariants
|
|
||||||
|
|
||||||
Complete declaration: `contracts/kanban-schema.v1.ts`.
|
|
||||||
|
|
||||||
- Tables: tenant/identity (`workspaces`, members, teams/members, agents/sessions); planning (`projects`, `milestones`, current-milestone join, `missions`, mission-milestones, `tasks`, normalized tags, dependencies); orchestration (`task_assignments`, durable execution state, leases, checkpoints/evidence); governance (`change_proposals`, immutable artifacts/evidence, events, approvals, outbox, external links).
|
|
||||||
- Task statuses: `backlog | ready | in_progress | blocked | in_review | done | cancelled`.
|
|
||||||
- Assignment states everywhere: `awaiting_approval | policy_pre_authorized | approved | rejected | leased | released | expired | superseded`.
|
|
||||||
- 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.
|
|
||||||
- Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations.
|
|
||||||
- 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.
|
|
||||||
- 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.
|
|
||||||
- Assignment is persisted with task/version, exact target/session, expiry/state/policy/proposer/reason. Approval relates to assignment. Lease acquisition accepts IDs, then reloads/locks and validates every relation.
|
|
||||||
- `tasks.fencing_counter` is bigint; locked atomic increment/RETURNING creates a decimal-string lease token. Lease/checkpoint composites bind exact workspace+task+assignment/session+fence.
|
|
||||||
- `task_execution_states` durably records retry/quarantine/exhaustion.
|
|
||||||
- Tags are normalized; legacy `tasks.tags` remains through N-1. Archive is explicit actor/reason/time and does not change lifecycle.
|
|
||||||
- Canonical parents use RESTRICT. Events/checkpoints/artifacts/evidence are INSERT/SELECT-only for application roles. Normal flow archives/cancels; purge is audited break-glass retention work.
|
|
||||||
|
|
||||||
## 4. Outage proposal contract
|
|
||||||
|
|
||||||
`change_proposals` stores workspace, active-member proposer, source-note digest, target/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, proposal version, and submit/accepted event IDs. Both event IDs are workspace-aware composite foreign keys to `task_events(workspace_id, id)`; a bare UUID is never sufficient.
|
|
||||||
|
|
||||||
Submission preallocates the proposal ID. One transaction inserts `change_proposal.submitted` with the proposal workspace, `aggregate_type='change_proposal'`, `aggregate_id=<new proposal ID>`, `previous_version=NULL`, and `new_version=1`, then inserts the proposal referencing that event. Missing, foreign-workspace, wrong-type, or unrelated-proposal events abort the transaction.
|
|
||||||
|
|
||||||
Submit/list/get/accept/reject are explicit Gateway commands. Pending/rejected proposals are inert: no scheduling, dependency/gate satisfaction, or direct target mutation. Acceptance locks proposal+target, obtains fresh transaction-local proof, verifies pending/expected version, invokes the normal command handler, and atomically stores the emitted normal-command event ID. That event must share the proposal workspace, match `target_aggregate_type` and `target_aggregate_id`, use `causation_id=submitted_audit_event_id`, and carry `payload.changeProposalId=<locked proposal ID>`. Missing, foreign-workspace, unrelated-target, unrelated-proposal, or unrelated-command events abort acceptance.
|
|
||||||
|
|
||||||
## 5. Concrete current-main N-1 migration delta
|
|
||||||
|
|
||||||
**Inspected:** `origin/main:packages/db/src/schema.ts` at `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13). It has global teams/no workspace keys, legacy project/mission/task statuses, nullable task project/mission, `tasks.assignee/tags/due_date`, mission JSON/config, duplicated `mission_tasks.status`, legacy agent fields, and separate fleet `backlog` claims.
|
|
||||||
|
|
||||||
Legacy columns remain declared in unified `schema.ts` for expand + full N-1/rollback window. Generation must not infer early drops.
|
|
||||||
|
|
||||||
### 5.1 Ordered phases
|
|
||||||
|
|
||||||
1. **Pre-expand:** N-1 patch stops `mission_tasks.status` as write source; inventory writers; backup/checksum.
|
|
||||||
2. **Expand:** add enums/tables and nullable-first columns; retain legacy declarations/uniques; emit no v1-only status.
|
|
||||||
3. **Backfill:** bootstrap workspace; bounded idempotent cursor/checksum batches; quarantine ambiguous rows.
|
|
||||||
4. **Validate:** no null tenant, cross-project link, ambiguous owner; status/tag/date/config retention; then constraints/NOT NULL.
|
|
||||||
5. **Compatibility:** N-1 reads legacy; same-DB transaction mirrors only unavoidable fields; never file/Valkey dual write.
|
|
||||||
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.
|
|
||||||
|
|
||||||
### 5.2 New audit/proposal DDL order
|
|
||||||
|
|
||||||
KBN-100 migration DDL must execute in this order:
|
|
||||||
|
|
||||||
1. create `task_events` and its unique `(workspace_id, id)` key;
|
|
||||||
2. create `change_proposals` with nullable acceptance-event ID and required submission-event ID;
|
|
||||||
3. add `change_proposals_workspace_submitted_event_fk` from `(workspace_id, submitted_audit_event_id)` to `task_events(workspace_id, id)` with `ON DELETE RESTRICT`;
|
|
||||||
4. add `change_proposals_workspace_accepted_command_event_fk` from `(workspace_id, accepted_command_audit_event_id)` to the same composite key with `ON DELETE RESTRICT`;
|
|
||||||
5. install application-role immutability privileges and same-transaction semantic validation before enabling proposal commands.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
| `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 |
|
|
||||||
| 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 |
|
|
||||||
| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review |
|
|
||||||
| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority |
|
|
||||||
| 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 |
|
|
||||||
| mission config/metadata/phase/user | retain all; map known typed policy only | all remain declared | remove only by signed consumer inventory |
|
|
||||||
| nullable `tasks.project_id` | derive explicit/mission project; orphan quarantine | retain nullable read/write during compatibility | canonical required; NOT NULL later |
|
|
||||||
| `tasks.mission_id` | add project-congruent composite | old relation readable | composite authority |
|
|
||||||
| `tasks.status` | canonical: not-started→backlog, in-progress→in_progress, others same | no ready/in_review emission | canonical 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.tags` JSON | normalize trim/case/dedupe with original digest | transactionally mirror normalized rows | normalized authority; JSON later removed |
|
|
||||||
| `tasks.due_date` | copy exactly to `due_at` | mirror | due_at authority; legacy later |
|
|
||||||
| task common fields | preserve metadata byte-for-byte; add criteria/rank/retry/archive/version/fence | old reads valid | new fields canonical |
|
|
||||||
| `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 |
|
|
||||||
| mission-task notes/PR/user | map to metadata/artifact/event/link/attribution; preserve | read-only | remove after parity |
|
|
||||||
| `agents.status` | add workspace/lifecycle/runtime/roles; status remains presence | retain all legacy fields | lifecycle/roles authority; status may remain telemetry |
|
|
||||||
| 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
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
coder2 solely owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, journal/metadata, and migration tests. No other lane generates migrations. Expand is additive; no drop/rename/narrow; constraints validate before NOT NULL; compatibility is same-DB only; contract is later.
|
|
||||||
|
|
||||||
KBN-200/coder4 owns pure `MechanicalCoordinatorDecisionEngineV1`: complete immutable snapshots in, deterministic eligibility/proposal/retry decisions out; no ID loading, SQL, Gateway, Valkey, proof, persistence, restart I/O, or LLM.
|
|
||||||
|
|
||||||
KBN-210/coder3 owns `MechanicalCoordinatorServicePortV1`: ID loading, locks, fresh proof, assignment/approval persistence, atomic fencing, lease/checkpoint/outbox, Valkey wakes, durable retry/quarantine, and `recoverFromPostgres`. Cycle: load snapshots → pure decision → persist assignment → authoritative approval/policy → acquire by IDs/locks → increment fence → lease → ack/heartbeat/checkpoint → submit to review or durable retry/quarantine. No completion/certification/merge method exists.
|
|
||||||
|
|
||||||
## 7. Exact Gateway/DTO freeze for KBN-105
|
|
||||||
|
|
||||||
### 7.1 Common wire rules
|
|
||||||
|
|
||||||
Base is `/api/v1/workspaces/:workspaceId`. Mutations require header `Idempotency-Key` (1–128 chars). Existing-aggregate mutations also require `If-Match-Version` (positive integer); create and privileged assignment-cycle requests are the only exceptions, while proposal submission carries `expectedTargetVersion` in its body. Body workspace fields are forbidden. Tenant denial follows one 404/403 policy without foreign existence detail.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface SuccessEnvelopeV1<T> {
|
|
||||||
contractVersion: '1.0.0';
|
|
||||||
data: T;
|
|
||||||
aggregateRevision: string;
|
|
||||||
correlationId: string;
|
|
||||||
}
|
|
||||||
interface ListEnvelopeV1<T> extends SuccessEnvelopeV1<T[]> {
|
|
||||||
page: { cursor: string | null; nextCursor: string | null; limit: number };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Errors are the exact health/transport/version unions in §2 plus validation/auth/not-found. Public DTOs never expose/accept internal write proof.
|
|
||||||
|
|
||||||
### 7.2 Exact route registry
|
|
||||||
|
|
||||||
| Method/path | Request body/query | Success data |
|
|
||||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
|
|
||||||
| `GET /kanban-health` | none | `KanbanHealthResponseV1` |
|
|
||||||
| `GET /projects` | `status,ownerUserId,ownerTeamId,cursor,limit` | project list |
|
|
||||||
| `POST /projects` | `name,key,description,status,priority,ownerUserId XOR ownerTeamId,metadata` | project |
|
|
||||||
| `GET /projects/:projectId` | none | project |
|
|
||||||
| `PATCH /projects/:projectId` | editable create fields + expected header | project |
|
|
||||||
| `POST /projects/:projectId/archive` | `reason` | project |
|
|
||||||
| `GET /tasks` | `projectId,missionId,milestoneId,status,priority,ownerUserId,ownerTeamId,specialistRole,tag,dueState,archived,cursor,limit` | task summary list |
|
|
||||||
| `POST /tasks` | `projectId,missionId?,milestoneId?,parentTaskId?,title,description?,acceptanceCriteria[],status,priority,rank,ownerUserId XOR ownerTeamId,specialistRole?,dueAt?,notBeforeAt?,estimateMinutes?,retryPolicy?,tagIds[],metadata` | task detail |
|
|
||||||
| `GET /tasks/:taskId` | none | task detail including readiness/dependencies/assignment/lease/events |
|
|
||||||
| `PATCH /tasks/:taskId` | editable non-transition fields | task detail |
|
|
||||||
| `POST /tasks/:taskId/transition` | `toStatus,reason?` | task detail |
|
|
||||||
| `POST /tasks/:taskId/move` | `toStatus?,beforeTaskId?,afterTaskId?` | task detail with persisted rank |
|
|
||||||
| `POST /tasks/:taskId/archive` | `reason` | task detail |
|
|
||||||
| `PUT /tasks/:taskId/tags` | `tagIds[]` | task detail |
|
|
||||||
| `POST /tasks/:taskId/dependencies` | `predecessorTaskId,type` | dependency |
|
|
||||||
| `DELETE /tasks/:taskId/dependencies/:predecessorTaskId` | no body | deleted dependency ID |
|
|
||||||
| `GET /tasks/:taskId/events` | `cursor,limit` | event list |
|
|
||||||
| `GET /tags` | `query,cursor,limit` | tag list |
|
|
||||||
| `POST /tags` | `name,color?` | tag |
|
|
||||||
| `GET /change-proposals` | `state,targetType,targetId,cursor,limit` | proposal list |
|
|
||||||
| `POST /change-proposals` | `sourceNoteDigest,targetType,targetId,expectedTargetVersion,commandType,commandPayload` | inert proposal |
|
|
||||||
| `GET /change-proposals/:proposalId` | none | proposal |
|
|
||||||
| `POST /change-proposals/:proposalId/accept` | `reason` | proposal + normal command result |
|
|
||||||
| `POST /change-proposals/:proposalId/reject` | `reason` | proposal |
|
|
||||||
| `GET /coordinator/eligibility` | `projectId?,missionId?,cursor,limit` | `EligibilityDecisionV1[]` |
|
|
||||||
| `POST /coordinator/assignment-cycles` | `limit` | assignment proposals; privileged internal |
|
|
||||||
| `POST /coordinator/assignments/:assignmentId/approve` | `decision,reason,policyRevision,artifactIds[]` | approval decision |
|
|
||||||
| `POST /coordinator/leases/acquire` | `taskId,assignmentId,approvalDecisionId,targetSessionId,leaseTtlSeconds` | lease with decimal-string fence |
|
|
||||||
| `POST /coordinator/leases/:leaseId/ack` | `taskId,sessionId,fencingToken` | lease |
|
|
||||||
| `POST /coordinator/leases/:leaseId/heartbeat` | `taskId,sessionId,fencingToken,extendSeconds` | lease |
|
|
||||||
| `POST /coordinator/leases/:leaseId/checkpoints` | `taskId,sessionId,fencingToken,sequence,resumableSummary,artifactIds[],contextUsagePercent` | checkpoint |
|
|
||||||
| `POST /coordinator/leases/:leaseId/submit-review` | `taskId,sessionId,fencingToken,artifactIds[],summary` | task in `in_review` |
|
|
||||||
|
|
||||||
All Coordinator mutations except human approval are service-identity-only. Generic task PATCH cannot perform claim/heartbeat/checkpoint/review/certification/completion shortcuts. Completion after certification uses a separately gated lifecycle command owned by the Portfolio/Sub-Orchestrator flow, not the Coordinator.
|
|
||||||
|
|
||||||
### 7.3 DTO invariants
|
|
||||||
|
|
||||||
Task summary/detail use exact schema vocabularies, owner union, `version: number`, `fencingCounter: string`, explicit `archivedAt/by/reason`, normalized tags, computed readiness, and separate assignment/lease. Assignment DTO includes one persisted ID, task/version, exact principal/agent/session, role, state, expiry, policy, proposer/reason. Lease/checkpoint DTOs serialize every fence as decimal string. Proposal DTO exposes no hidden write authority.
|
|
||||||
|
|
||||||
### 7.4 MCP ownership and mapping
|
|
||||||
|
|
||||||
coder3 exclusively owns:
|
|
||||||
|
|
||||||
- `apps/gateway/src/mcp/mcp.dto.ts`
|
|
||||||
- `mcp.controller.ts`
|
|
||||||
- `mcp.service.ts`
|
|
||||||
- `mcp.module.ts`
|
|
||||||
- `mcp.tokens.ts`
|
|
||||||
- `mcp.service.spec.ts`
|
|
||||||
|
|
||||||
MCP tools are thin maps: `mosaic_projects_{list,get,create,update,archive}`, `mosaic_tasks_{list,get,create,update,transition,move,archive,set_tags,add_dependency,remove_dependency}`, and `mosaic_change_proposals_{list,get,submit,accept,reject}` to the exact routes above. coder4 owns CLI/projection clients only and must not edit Gateway MCP files.
|
|
||||||
|
|
||||||
KBN-105 publishes route+DTO fixture digest before KBN-110/120/130. Every web/CLI/MCP call must match this registry and the generated client.
|
|
||||||
|
|
||||||
## 8. Recovery contract and bounded delivery slice
|
|
||||||
|
|
||||||
Runtime must invoke normative `validateRecoveryPostureV1`; JSON Schema alone is insufficient. It rejects unknown fields, PITR/WAL mismatch, RPO better than mechanism, unsafe storage, and weakened High-assurance. High-assurance is RPO 15m/RTO 4h, WAL ≤5m, PITR ≥35d, base ≤24h, restore test ≤30d, break-glass ≤90d, encrypted separate-failure-domain storage.
|
|
||||||
|
|
||||||
KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recovery runbook. It wires parser/refinement, override audit, mechanism assertions, restore test, and break-glass evidence. Any deployment manifest is separately enumerated and Mos-serialized. Recovery config has no SOT/gate/Coordinator authority fields.
|
|
||||||
|
|
||||||
## 9. Integration, security, and hold
|
|
||||||
|
|
||||||
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-001–016. Mos alone releases waves and serializes integration roots.
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
# Native Kanban/SOT P0–P3 — Dependency-Ordered Build Slices
|
|
||||||
|
|
||||||
**Status:** CANON INDEPENDENTLY APPROVED; PUBLICATION IN PROGRESS
|
|
||||||
**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
|
||||||
**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes
|
|
||||||
**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files
|
|
||||||
**Implementation hold:** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete.
|
|
||||||
|
|
||||||
> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported.
|
|
||||||
|
|
||||||
## Execution invariants
|
|
||||||
|
|
||||||
- PostgreSQL is the sole writable SOT; current-main Drizzle is the persistence foundation.
|
|
||||||
- Mutations require fresh internal PostgreSQL transaction-local write proof and fail closed otherwise.
|
|
||||||
- Public health DTOs, Valkey, files, browser state, providers, and outage notes cannot authorize writes.
|
|
||||||
- Outage notes return only through attributable `change_proposals`; proposal acceptance executes the normal command.
|
|
||||||
- Mechanical Coordinator is non-LLM and cannot invent scope, waive gates, certify, or merge.
|
|
||||||
- Certifier is final independent gate with no merge authority.
|
|
||||||
- Workspace is the hard tenant. Project hierarchy is project-congruent. Assignment, approval, lease, fence, checkpoint, and evidence are relationally bound.
|
|
||||||
- Recovery tiers change recovery posture only.
|
|
||||||
|
|
||||||
## 1. Collision-free ownership
|
|
||||||
|
|
||||||
| USC lane | Exclusive ownership | Must not edit |
|
|
||||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
||||||
| **coder2 — schema/recovery** | `packages/db/src/schema.ts`; `packages/db/drizzle/**`; DB tests; `packages/config/src/recovery-posture.ts`; `packages/config/src/recovery-posture.spec.ts`; `docs/runbooks/kanban-postgres-recovery.md` | Gateway, Brain repositories, Coordinator, web, CLI/importer |
|
|
||||||
| **coder3 — domain/Gateway/MCP server** | Kanban repositories under `packages/brain/src/`; Gateway workspace/project/mission/milestone/task/kanban/health/coord modules; **exact MCP files:** `apps/gateway/src/mcp/mcp.dto.ts`, `mcp.controller.ts`, `mcp.service.ts`, `mcp.module.ts`, `mcp.tokens.ts`, `mcp.service.spec.ts`; Gateway root wiring/tests | DB schema/migrations, `packages/coord`, web, CLI/importer |
|
|
||||||
| **coder4 — CLI → pure Coordinator → migration tooling** | In this one fixed lane order: KBN-120 (`packages/mosaic` CLI/projection) → KBN-200 (`packages/coord/src/mechanical/**`) → KBN-300/320 (`scripts/kanban-migration/**`) | DB, Gateway/MCP server, web |
|
|
||||||
| **coder5 — web** | `apps/web/src/app/(dashboard)/{tasks,projects}/**`; `apps/web/src/components/{tasks,projects}/**`; Kanban web API/types; later Coordinator/migration-review routes | DB, Gateway, Coordinator, CLI/importer |
|
|
||||||
| **Mos — publication/integration** | Contract amendments, exact endpoint registry publication, serialized root exports/manifests/lockfiles, integration gates | Active lane feature files |
|
|
||||||
|
|
||||||
Shared roots, package exports/manifests, lockfiles, and generated artifacts are integration-serialized. Contract changes stop affected lanes and require Mos approval.
|
|
||||||
|
|
||||||
## 2. Parallelization legend
|
|
||||||
|
|
||||||
- **SERIAL:** prerequisite must be complete and reviewed.
|
|
||||||
- **PARALLEL-GROUP:** disjoint files and exact frozen contract permit concurrent work.
|
|
||||||
- **LANE-SERIAL:** one lane's stated order cannot change.
|
|
||||||
- **INTEGRATION-SERIAL:** component heads green first; semantic findings return to owner.
|
|
||||||
|
|
||||||
## 3. Corrected dependency graph
|
|
||||||
|
|
||||||
```text
|
|
||||||
KBN-000 canon remediation
|
|
||||||
-> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE)
|
|
||||||
-> KBN-100 schema + concrete N-1 migration implementation
|
|
||||||
├─ KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL)
|
|
||||||
│ ├─ KBN-110 domain + Gateway + MCP server implementation
|
|
||||||
│ ├─ KBN-120 CLI/projection implementation [coder4 first]
|
|
||||||
│ └─ KBN-130 web MVP implementation
|
|
||||||
└─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
|
|
||||||
KBN-110 + KBN-120 + KBN-130 + KBN-115
|
|
||||||
-> KBN-140 P1 integration/SIT
|
|
||||||
-> KBN-200 pure decision engine [coder4 after KBN-120]
|
|
||||||
-> KBN-210 persistence/service adapter + approval/lease binding
|
|
||||||
-> KBN-220 Coordinator operations UI
|
|
||||||
-> KBN-230 P2 concurrency/fault/gate integration
|
|
||||||
KBN-230
|
|
||||||
-> KBN-300 importer dry-run/apply/verify [coder4 after KBN-200]
|
|
||||||
├─ KBN-310 migration reviewer UI
|
|
||||||
└─ KBN-320 cutover/rollback tooling [coder4 after KBN-300]
|
|
||||||
KBN-310 + KBN-320
|
|
||||||
-> KBN-330 rehearsal/reconciliation
|
|
||||||
-> KBN-340 owner-gated cutover/stabilization
|
|
||||||
```
|
|
||||||
|
|
||||||
No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320.
|
|
||||||
|
|
||||||
## 4. P0 — Canon, threat gate, schema, and exact API freeze
|
|
||||||
|
|
||||||
### KBN-000 — Remediate and publish canon
|
|
||||||
|
|
||||||
- **Owner:** Mos / publication control plane.
|
|
||||||
- **Mode:** SERIAL; publication gate in progress.
|
|
||||||
- **IN:** Resolve KCR-001–016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review.
|
|
||||||
- **OUT:** Feature implementation.
|
|
||||||
- **Depends on:** none.
|
|
||||||
- **Contract surfaces:** all canon.
|
|
||||||
- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO.
|
|
||||||
|
|
||||||
### KBN-010 — Threat, authorization, and constraint-impact gate
|
|
||||||
|
|
||||||
- **Owner:** coder3; independent `secrev`.
|
|
||||||
- **Mode:** SERIAL prerequisite of KBN-100.
|
|
||||||
- **Exclusive files:** Mos-selected threat/auth docs 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.
|
|
||||||
- **OUT:** Runtime/schema edits.
|
|
||||||
- **Depends on:** KBN-000 independent re-review GO.
|
|
||||||
- **Contract surfaces:** schema constraints, health proof, exact errors, command-family authorization.
|
|
||||||
- **Evidence:** signed constraint-impact matrix; no unresolved schema-impact finding; SecReview pass.
|
|
||||||
|
|
||||||
### KBN-100 — Unified Drizzle schema and concrete N-1 migration
|
|
||||||
|
|
||||||
- **Owner:** **coder2**.
|
|
||||||
- **Mode:** SERIAL.
|
|
||||||
- **Exclusive files:** `packages/db/src/schema.ts`, `packages/db/drizzle/**`, DB tests.
|
|
||||||
- **IN:** All frozen tables/joins/enums; workspace/project-congruent constraints; owners/principals; tags/archive; change proposals with both workspace-aware task-event composite FKs and frozen event-before-proposal DDL order; assignment approvals; durable execution/quarantine; monotonic bigint fence; exact checkpoint/evidence joins; RESTRICT/immutability; concrete current-main expand/backfill/switch/contract map.
|
|
||||||
- **OUT:** Repositories, Gateway, Coordinator behavior, UI, importer.
|
|
||||||
- **Depends on:** **KBN-010 completed**.
|
|
||||||
- **Contract surfaces:** `kanban-schema.v1.ts`; SHARED-CONTRACT current-main delta map.
|
|
||||||
- **Evidence:** reviewed SQL; empty/prod-shape/partial-resume/rollback tests; N-1 app safety; legacy columns remain declared; workspace/project mismatch negatives; proposal event-FK missing/foreign-workspace tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; SecReview.
|
|
||||||
|
|
||||||
### KBN-105 — Exact Gateway/MCP endpoint, DTO, and error freeze
|
|
||||||
|
|
||||||
- **Owner:** Mos + coder3 contract author; independent endpoint-alignment reviewer.
|
|
||||||
- **Mode:** SERIAL after KBN-100; prerequisite for KBN-110/120/130.
|
|
||||||
- **Exclusive files:** canonical endpoint-registry/DTO contract docs; no implementation.
|
|
||||||
- **IN:** Exact routes and methods from SHARED-CONTRACT §8; request/success/error fields; status codes; pagination/filter/revision envelopes; idempotency/expected-version headers/fields; proposal commands; health proof exclusion from public DTOs; MCP tool-to-route map.
|
|
||||||
- **OUT:** Controller/service/client implementation.
|
|
||||||
- **Depends on:** KBN-100.
|
|
||||||
- **Contract surfaces:** health/error unions; schema IDs/statuses; Gateway DTO freeze.
|
|
||||||
- **Evidence:** every FE/CLI/MCP call maps 1:1 to a route; 503/502-504/409 non-cross-map fixtures; contract digest published.
|
|
||||||
|
|
||||||
### KBN-115 — Recovery posture parser, mechanisms, and evidence
|
|
||||||
|
|
||||||
- **Owner:** **coder2**, lane-serial after KBN-100.
|
|
||||||
- **Mode:** PARALLEL with KBN-110/120/130 after KBN-105.
|
|
||||||
- **Exclusive files:** `packages/config/src/recovery-posture.ts`, `.spec.ts`, `docs/runbooks/kanban-postgres-recovery.md`; deployment-specific backup manifest changes are a separately enumerated Mos integration patch.
|
|
||||||
- **IN:** Wire normative `validateRecoveryPostureV1`; override audit; backup/WAL/PITR mechanism assertions; off-cluster encryption/failure-domain checks; restore and break-glass evidence procedure.
|
|
||||||
- **OUT:** SOT/gate/Coordinator policy knobs; DB business schema.
|
|
||||||
- **Depends on:** KBN-100, KBN-105.
|
|
||||||
- **Contract surfaces:** `recovery-posture.v1.ts` only.
|
|
||||||
- **Evidence:** impossible-combination tests; High-assurance weakening tests; selected-tier mechanism verification; restore and break-glass evidence; SecReview.
|
|
||||||
|
|
||||||
## 5. P1 — Thin native MVP
|
|
||||||
|
|
||||||
### KBN-110 — Workspace-safe domain, Gateway, MCP server, and proposal commands
|
|
||||||
|
|
||||||
- **Owner:** **coder3**.
|
|
||||||
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
|
|
||||||
- **Exclusive files:** ownership map, including all exact MCP server files listed there.
|
|
||||||
- **IN:** Workspace-safe repositories; project/task/dependency/tag/archive CRUD; transitions; exact owners; assignment/approval/link/artifact queries; submit/query/accept/reject change proposals; health endpoint; internal write-proof mint/revalidation; event/outbox atomicity; frozen DTOs/routes.
|
|
||||||
- **OUT:** Scheduling algorithm, web, CLI, DB schema.
|
|
||||||
- **Depends on:** KBN-100, KBN-105.
|
|
||||||
- **Contract surfaces:** all four TypeScript contracts and exact registry.
|
|
||||||
- **Evidence:** DTO/service/controller/integration tests; active-membership and no-oracle negatives; proposal cannot mutate directly; submission event is the new proposal's exact `change_proposal.submitted` event; acceptance links the executed normal command for the locked proposal and same workspace/target; missing, foreign-workspace, unrelated-proposal/target/command event negatives; exact failure mapping; endpoint registry; SecReview.
|
|
||||||
|
|
||||||
### KBN-120 — CLI, MCP client mapping, and generated projection
|
|
||||||
|
|
||||||
- **Owner:** **coder4**; first coder4 slice.
|
|
||||||
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
|
|
||||||
- **Exclusive files:** `packages/mosaic/src/commands/{kanban,tasks,projects}.ts`; `packages/mosaic/src/projections/**`; tests. **No `apps/gateway/src/mcp/**` edits.\*\*
|
|
||||||
- **IN:** Frozen query/mutation routes; proposal commands; compact context; generated `TASKS.md`; deliberate denial/transport/conflict handling.
|
|
||||||
- **OUT:** Gateway/MCP server, file importer, raw SQL/Valkey, Coordinator.
|
|
||||||
- **Depends on:** KBN-105; runtime integration later requires KBN-110.
|
|
||||||
- **Evidence:** contract fixtures; same revision; no import parser; same idempotency key on transport retry; 503 never auto-retried.
|
|
||||||
|
|
||||||
### KBN-130 — Writable Kanban/List and minimal Projects UI
|
|
||||||
|
|
||||||
- **Owner:** **coder5**.
|
|
||||||
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
|
|
||||||
- **Exclusive files:** web ownership map.
|
|
||||||
- **IN:** Workspace context; projects; tasks; tags; explicit archive; detail; accessible move/reorder; filters; dependency/readiness; owner/assignment/lease; audit; proposal visibility; conflict/loading/error/reconnect.
|
|
||||||
- **OUT:** Gateway/schema, Coordinator operations UI, migration UI.
|
|
||||||
- **Depends on:** KBN-105; runtime integration later requires KBN-110.
|
|
||||||
- **Evidence:** frozen contract mocks; real-Gateway journeys; keyboard/non-drag; tags/archive semantics; no-oracle tenant negatives; 503/transport/409 distinct UI.
|
|
||||||
|
|
||||||
### KBN-140 — P1 integration and situational gate
|
|
||||||
|
|
||||||
- **Owner:** Mos integration; independent reviewer/SecReview/Certifier.
|
|
||||||
- **Mode:** INTEGRATION-SERIAL.
|
|
||||||
- **IN:** KBN-110/120/130/115; unavoidable root exports only.
|
|
||||||
- **OUT:** P2 behavior.
|
|
||||||
- **Depends on:** KBN-110, KBN-120, KBN-130, KBN-115.
|
|
||||||
- **Evidence:** clean migration; web/CLI/MCP/projection revision parity; forged/expired health negatives; change-proposal event-chain success plus missing/foreign/unrelated-event negatives; tag/archive; tenant negatives; endpoint registry; author-independent review; Certifier pass.
|
|
||||||
|
|
||||||
## 6. P2 — Mechanical Coordinator
|
|
||||||
|
|
||||||
### KBN-200 — Pure deterministic decision engine
|
|
||||||
|
|
||||||
- **Owner:** **coder4**; second coder4 slice, strictly after KBN-120.
|
|
||||||
- **Mode:** SERIAL in coder4 lane.
|
|
||||||
- **Exclusive files:** `packages/coord/src/mechanical/**` and pure tests.
|
|
||||||
- **IN:** `MechanicalCoordinatorDecisionEngineV1`; complete immutable snapshots; eligibility/explanation; fairness/order; capability matching; expiry/retry/quarantine decisions.
|
|
||||||
- **OUT:** ID loading, PostgreSQL, Drizzle, Gateway, Valkey, health-proof minting, persistence, `recoverFromPostgres`, LLM calls.
|
|
||||||
- **Depends on:** KBN-140 (or Mos may release after KBN-120 + frozen types if no P1 semantic risk remains).
|
|
||||||
- **Evidence:** deterministic/property tests; snapshot completeness; no I/O/model imports; no authority methods.
|
|
||||||
|
|
||||||
### KBN-210 — Coordinator persistence/service adapter and approval-bound leases
|
|
||||||
|
|
||||||
- **Owner:** **coder3**.
|
|
||||||
- **Mode:** SERIAL after KBN-200.
|
|
||||||
- **Exclusive files:** Gateway `coord` and repositories.
|
|
||||||
- **IN:** `MechanicalCoordinatorServicePortV1`; snapshot loading; proposal persistence; manual/versioned policy approval; acquire by IDs; reload+lock task/assignment/approval/session; fresh txn-local write proof; atomic task fence increment; lease/ack/heartbeat/checkpoint/submit; durable retry/quarantine; outbox/Valkey wake; restart recovery.
|
|
||||||
- **OUT:** Pure algorithm, UI, DB schema.
|
|
||||||
- **Depends on:** KBN-110, KBN-200.
|
|
||||||
- **Evidence:** forged/stale approval rejection; target/session/version/expiry/policy checks; concurrent monotonic fences; same-workspace mismatch negatives; bigint precision; stale worker rejection; DB/Valkey faults; SecReview.
|
|
||||||
|
|
||||||
### KBN-220 — Coordinator operations UI
|
|
||||||
|
|
||||||
- **Owner:** **coder5**.
|
|
||||||
- **Mode:** after KBN-210 exact DTO freeze.
|
|
||||||
- **IN:** Roster; eligibility; persisted assignment state; approvals/overrides; exact lease/fence; durable retry/quarantine; role/gate/Certifier visibility.
|
|
||||||
- **OUT:** Scheduling decisions, schema, merge control for Certifier.
|
|
||||||
- **Depends on:** KBN-210.
|
|
||||||
- **Evidence:** authorized journeys; reason required; stale refresh; no Certifier merge; endpoint alignment/accessibility.
|
|
||||||
|
|
||||||
### KBN-230 — P2 concurrency/fault/gate integration
|
|
||||||
|
|
||||||
- **Owner:** Mos integration; independent reviewer/SecReview/Certifier.
|
|
||||||
- **Mode:** INTEGRATION-SERIAL.
|
|
||||||
- **Depends on:** KBN-200, KBN-210, KBN-220.
|
|
||||||
- **Evidence:** one lease; monotonic fences; exact relational mismatches rejected; expired proof; forged healthy; approval binding; restart; durable quarantine; outbox recovery; author≠reviewer; Certifier final/no merge.
|
|
||||||
|
|
||||||
## 7. P3 — Shadow migration and cutover
|
|
||||||
|
|
||||||
### KBN-300 — One-way importer dry-run/apply/verify
|
|
||||||
|
|
||||||
- **Owner:** **coder4**; third coder4 slice.
|
|
||||||
- **Mode:** after KBN-230.
|
|
||||||
- **Exclusive files:** `scripts/kanban-migration/import/**`.
|
|
||||||
- **IN:** Immutable jarvis-brain/Vikunja snapshots; deterministic mapping; source digest/lineage; Gateway writes; rejects; no dispatch.
|
|
||||||
- **OUT:** Bidirectional sync, direct DB/file canonical writes, unrelated brain data.
|
|
||||||
- **Depends on:** KBN-230.
|
|
||||||
- **Evidence:** idempotency; counts/fields; malformed/foreign rejects; no dispatch; SecReview.
|
|
||||||
|
|
||||||
### KBN-310 — Shadow reviewer UI
|
|
||||||
|
|
||||||
- **Owner:** **coder5**.
|
|
||||||
- **Mode:** PARALLEL-GROUP P3-A after KBN-300 report freeze.
|
|
||||||
- **IN:** Read-only counts/diffs/rejects/lineage/sign-off.
|
|
||||||
- **OUT:** Apply/cutover mutations.
|
|
||||||
- **Depends on:** KBN-300.
|
|
||||||
- **Evidence:** read-only and tenant tests; pagination/accessibility.
|
|
||||||
|
|
||||||
### KBN-320 — Cutover/rollback tooling
|
|
||||||
|
|
||||||
- **Owner:** **coder4**; fourth coder4 slice, after KBN-300.
|
|
||||||
- **Mode:** PARALLEL-GROUP P3-A with KBN-310.
|
|
||||||
- **Exclusive files:** `scripts/kanban-migration/cutover/**`.
|
|
||||||
- **IN:** Freeze assertion; backup/checksum; final delta; client switch; legacy writer/credential shutdown; rollback delta; stabilization.
|
|
||||||
- **OUT:** Destructive deletion, reverse sync, ungated production execution.
|
|
||||||
- **Depends on:** KBN-300.
|
|
||||||
- **Evidence:** fail-safe rehearsal; no dual writer; rollback authority; SecReview.
|
|
||||||
|
|
||||||
### KBN-330 — Migration rehearsal/reconciliation
|
|
||||||
|
|
||||||
- **Owner:** Mos + coder4 support + independent data reviewer.
|
|
||||||
- **Mode:** INTEGRATION-SERIAL.
|
|
||||||
- **Depends on:** KBN-310, KBN-320.
|
|
||||||
- **Evidence:** signed exceptions; selected-tier restore; backlog hold; no legacy changes; Certifier readiness.
|
|
||||||
|
|
||||||
### KBN-340 — Final cutover/stabilization
|
|
||||||
|
|
||||||
- **Owner:** Mos/control plane; owner-gated operation.
|
|
||||||
- **Mode:** SERIAL.
|
|
||||||
- **Depends on:** KBN-330 PASS and Jason authorization.
|
|
||||||
- **Evidence:** no legacy writer; scoped Gateway identities; no accidental dispatch; terminal green health/CI; Certifier evidence; owner retirement approval.
|
|
||||||
|
|
||||||
## 8. Consistent USC wave schedule
|
|
||||||
|
|
||||||
| Wave | coder2 | coder3 | coder4 | coder5 |
|
|
||||||
| ---- | ------------------------- | -------------------------------------- | ------------------------------ | ------------------------------ |
|
|
||||||
| 0 | Wait | **KBN-010** | Wait | Wait |
|
|
||||||
| 1 | **KBN-100** | Review constraint implementation | Wait | Wait |
|
|
||||||
| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 |
|
|
||||||
| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 |
|
|
||||||
| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** |
|
|
||||||
| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** |
|
|
||||||
|
|
||||||
Mos alone releases slices and lifts the build hold after independent re-review GO.
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mosaic Native Kanban — frozen health/error contract v1.
|
|
||||||
* Publication contract only; no runtime implementation is included here.
|
|
||||||
*
|
|
||||||
* PostgreSQL is the sole writable SOT. Public health DTOs are observations,
|
|
||||||
* never write authority. Only an internal transaction-local proof produced by
|
|
||||||
* the PostgreSQL adapter may authorize a mutation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const KANBAN_CONTRACT_VERSION = '1.0.0' as const;
|
|
||||||
|
|
||||||
export const kanbanHealthStates = ['healthy', 'read-only-degraded', 'write-unavailable'] as const;
|
|
||||||
export type KanbanHealthState = (typeof kanbanHealthStates)[number];
|
|
||||||
|
|
||||||
interface KanbanHealthBaseV1 {
|
|
||||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
|
||||||
checkedAt: string;
|
|
||||||
/** Observation expires at this RFC 3339 instant; it still never authorizes writes. */
|
|
||||||
validUntil: string;
|
|
||||||
policyRevision: string;
|
|
||||||
reasons: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HealthyKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
|
|
||||||
state: 'healthy';
|
|
||||||
readHealthProven: true;
|
|
||||||
writeHealthProven: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReadOnlyDegradedKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
|
|
||||||
state: 'read-only-degraded';
|
|
||||||
readHealthProven: true;
|
|
||||||
writeHealthProven: false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WriteUnavailableKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
|
|
||||||
state: 'write-unavailable';
|
|
||||||
readHealthProven: false;
|
|
||||||
writeHealthProven: false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Public, discriminated observation. Contradictory combinations are unrepresentable. */
|
|
||||||
export type KanbanHealthResponseV1 =
|
|
||||||
| HealthyKanbanHealthResponseV1
|
|
||||||
| ReadOnlyDegradedKanbanHealthResponseV1
|
|
||||||
| WriteUnavailableKanbanHealthResponseV1;
|
|
||||||
|
|
||||||
/** Pure evaluation context. It cannot authorize a mutation. */
|
|
||||||
export interface KanbanEvaluationContextV1 {
|
|
||||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
|
||||||
workspaceId: string;
|
|
||||||
correlationId: string;
|
|
||||||
now: string;
|
|
||||||
policyRevision: string;
|
|
||||||
observedHealth: KanbanHealthResponseV1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Non-exported brand: public DTO deserialization cannot construct this type.
|
|
||||||
* The PostgreSQL adapter mints it only after a fresh write probe inside the same
|
|
||||||
* transaction and validates checkedAt <= now < validUntil and policy revision.
|
|
||||||
*/
|
|
||||||
declare const postgresWriteHealthProofBrand: unique symbol;
|
|
||||||
export interface PostgresWriteHealthProofV1 {
|
|
||||||
readonly [postgresWriteHealthProofBrand]: true;
|
|
||||||
readonly source: 'postgres-transaction-local-write-probe';
|
|
||||||
readonly transactionId: string;
|
|
||||||
readonly checkedAt: string;
|
|
||||||
readonly validUntil: string;
|
|
||||||
readonly policyRevision: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Internal mutation context; MUST NOT appear in REST/MCP/CLI request DTOs. */
|
|
||||||
export interface InternalKanbanMutationContextV1 {
|
|
||||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
|
||||||
workspaceId: string;
|
|
||||||
correlationId: string;
|
|
||||||
causationId?: string;
|
|
||||||
idempotencyKey: string;
|
|
||||||
now: string;
|
|
||||||
expectedPolicyRevision: string;
|
|
||||||
writeProof: PostgresWriteHealthProofV1;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MutationFailureBaseV1 {
|
|
||||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
|
||||||
retryable: false;
|
|
||||||
requestOutcome: 'not_applied';
|
|
||||||
idempotencyKey: string;
|
|
||||||
correlationId: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** KCR-016: code/state pairing is exact and cannot cross-map. */
|
|
||||||
export interface ReadOnlyWriteHealthDenialV1 extends MutationFailureBaseV1 {
|
|
||||||
kind: 'deliberate_fail_closed_denial';
|
|
||||||
code: 'KANBAN_WRITE_HEALTH_UNPROVEN';
|
|
||||||
healthState: 'read-only-degraded';
|
|
||||||
checkedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WriteUnavailableDenialV1 extends MutationFailureBaseV1 {
|
|
||||||
kind: 'deliberate_fail_closed_denial';
|
|
||||||
code: 'KANBAN_WRITE_UNAVAILABLE';
|
|
||||||
healthState: 'write-unavailable';
|
|
||||||
checkedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DeliberateWriteDenialV1 = ReadOnlyWriteHealthDenialV1 | WriteUnavailableDenialV1;
|
|
||||||
|
|
||||||
export const transportErrorCodes = [
|
|
||||||
'GATEWAY_UNREACHABLE',
|
|
||||||
'GATEWAY_TIMEOUT',
|
|
||||||
'UPSTREAM_BAD_GATEWAY',
|
|
||||||
] as const;
|
|
||||||
export type TransportErrorCode = (typeof transportErrorCodes)[number];
|
|
||||||
|
|
||||||
/** Client-normalized transport uncertainty; never an authoritative 503 body. */
|
|
||||||
export interface RetryableTransportErrorV1 {
|
|
||||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
|
||||||
kind: 'retryable_transport_error';
|
|
||||||
code: TransportErrorCode;
|
|
||||||
retryable: true;
|
|
||||||
requestOutcome: 'unknown';
|
|
||||||
/** Retry MUST reuse this exact key. */
|
|
||||||
idempotencyKey: string;
|
|
||||||
correlationId: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VersionConflictV1 {
|
|
||||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
|
||||||
kind: 'version_conflict';
|
|
||||||
code: 'AGGREGATE_VERSION_CONFLICT';
|
|
||||||
retryable: false;
|
|
||||||
requestOutcome: 'not_applied';
|
|
||||||
aggregateType: 'project' | 'mission' | 'milestone' | 'task' | 'change_proposal';
|
|
||||||
aggregateId: string;
|
|
||||||
expectedVersion: number;
|
|
||||||
actualVersion: number;
|
|
||||||
idempotencyKey: string;
|
|
||||||
correlationId: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type KanbanMutationFailureV1 =
|
|
||||||
| DeliberateWriteDenialV1
|
|
||||||
| RetryableTransportErrorV1
|
|
||||||
| VersionConflictV1;
|
|
||||||
|
|
||||||
export const kanbanHealthCapabilities: Readonly<
|
|
||||||
Record<KanbanHealthState, { canonicalReads: boolean; mutations: boolean }>
|
|
||||||
> = {
|
|
||||||
healthy: { canonicalReads: true, mutations: true },
|
|
||||||
'read-only-degraded': { canonicalReads: true, mutations: false },
|
|
||||||
'write-unavailable': { canonicalReads: false, mutations: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Exact HTTP/error normalization freeze; 503, transport, and 409 cannot cross-map. */
|
|
||||||
export const kanbanFailureHttpMapV1 = {
|
|
||||||
KANBAN_WRITE_HEALTH_UNPROVEN: {
|
|
||||||
httpStatus: 503,
|
|
||||||
kind: 'deliberate_fail_closed_denial',
|
|
||||||
requestOutcome: 'not_applied',
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
KANBAN_WRITE_UNAVAILABLE: {
|
|
||||||
httpStatus: 503,
|
|
||||||
kind: 'deliberate_fail_closed_denial',
|
|
||||||
requestOutcome: 'not_applied',
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
AGGREGATE_VERSION_CONFLICT: {
|
|
||||||
httpStatus: 409,
|
|
||||||
kind: 'version_conflict',
|
|
||||||
requestOutcome: 'not_applied',
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
GATEWAY_UNREACHABLE: {
|
|
||||||
httpStatus: 502,
|
|
||||||
kind: 'retryable_transport_error',
|
|
||||||
requestOutcome: 'unknown',
|
|
||||||
retryable: true,
|
|
||||||
},
|
|
||||||
GATEWAY_TIMEOUT: {
|
|
||||||
httpStatus: 504,
|
|
||||||
kind: 'retryable_transport_error',
|
|
||||||
requestOutcome: 'unknown',
|
|
||||||
retryable: true,
|
|
||||||
},
|
|
||||||
UPSTREAM_BAD_GATEWAY: {
|
|
||||||
httpStatus: 502,
|
|
||||||
kind: 'retryable_transport_error',
|
|
||||||
requestOutcome: 'unknown',
|
|
||||||
retryable: true,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Required negative contract tests:
|
|
||||||
* - contradictory state/proof booleans fail type/schema validation;
|
|
||||||
* - expired internal proof and policy mismatch deny before mutation;
|
|
||||||
* - Valkey-only liveness cannot mint PostgresWriteHealthProofV1;
|
|
||||||
* - public/caller-forged `healthy` cannot enter InternalKanbanMutationContextV1;
|
|
||||||
* - authoritative 503, transport 502/504/timeout, and 409 mappings are exhaustive.
|
|
||||||
*/
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,419 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mosaic Native Kanban — frozen Mechanical Coordinator contracts v1.
|
|
||||||
*
|
|
||||||
* The pure decision engine and persistence/orchestration service are separate.
|
|
||||||
* Neither surface can create scope, edit acceptance, waive gates, certify,
|
|
||||||
* merge, release a deployment, or close a provider issue.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
DeliberateWriteDenialV1,
|
|
||||||
InternalKanbanMutationContextV1,
|
|
||||||
KanbanEvaluationContextV1,
|
|
||||||
KanbanMutationFailureV1,
|
|
||||||
RetryableTransportErrorV1,
|
|
||||||
VersionConflictV1,
|
|
||||||
} from './health-state.v1.js';
|
|
||||||
|
|
||||||
export const COORDINATOR_CONTRACT_VERSION = '1.0.0' as const;
|
|
||||||
export type Uuid = string;
|
|
||||||
export type IsoTimestamp = string;
|
|
||||||
/** PostgreSQL bigint-safe decimal string; never a JavaScript number. */
|
|
||||||
export type FencingTokenV1 = string;
|
|
||||||
|
|
||||||
export const specialistRoles = [
|
|
||||||
'planning',
|
|
||||||
'enhance',
|
|
||||||
'coder',
|
|
||||||
'review',
|
|
||||||
'security-review',
|
|
||||||
'pr-monitor',
|
|
||||||
'certifier',
|
|
||||||
] as const;
|
|
||||||
export type SpecialistRole = (typeof specialistRoles)[number];
|
|
||||||
|
|
||||||
/** One vocabulary shared with task_assignment_state_v1 in the Drizzle schema. */
|
|
||||||
export const assignmentStates = [
|
|
||||||
'awaiting_approval',
|
|
||||||
'policy_pre_authorized',
|
|
||||||
'approved',
|
|
||||||
'rejected',
|
|
||||||
'leased',
|
|
||||||
'released',
|
|
||||||
'expired',
|
|
||||||
'superseded',
|
|
||||||
] as const;
|
|
||||||
export type AssignmentStateV1 = (typeof assignmentStates)[number];
|
|
||||||
|
|
||||||
export const readinessStates = [
|
|
||||||
'dependency-gated',
|
|
||||||
'schedule-gated',
|
|
||||||
'policy-gated',
|
|
||||||
'lease-available',
|
|
||||||
'leased',
|
|
||||||
'retry-delayed',
|
|
||||||
'exhausted',
|
|
||||||
'quarantined',
|
|
||||||
] as const;
|
|
||||||
export type ReadinessState = (typeof readinessStates)[number];
|
|
||||||
|
|
||||||
export interface RetryStateSnapshotV1 {
|
|
||||||
disposition: 'available' | 'retry_delayed' | 'quarantined' | 'exhausted';
|
|
||||||
attemptCount: number;
|
|
||||||
maxAttempts: number;
|
|
||||||
nextEligibleAt: IsoTimestamp | null;
|
|
||||||
idempotent: boolean;
|
|
||||||
terminalReason: string | null;
|
|
||||||
version: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TaskEligibilitySnapshotV1 {
|
|
||||||
workspaceId: Uuid;
|
|
||||||
taskId: Uuid;
|
|
||||||
taskVersion: number;
|
|
||||||
projectId: Uuid;
|
|
||||||
projectActive: boolean;
|
|
||||||
missionId: Uuid | null;
|
|
||||||
missionActive: boolean;
|
|
||||||
status: 'ready';
|
|
||||||
priority: 'critical' | 'high' | 'medium' | 'low';
|
|
||||||
boardRank: string;
|
|
||||||
dueAt: IsoTimestamp | null;
|
|
||||||
notBeforeAt: IsoTimestamp | null;
|
|
||||||
createdAt: IsoTimestamp;
|
|
||||||
requiredRole: SpecialistRole;
|
|
||||||
requiredCapabilities: readonly string[];
|
|
||||||
blockingDependencies: readonly {
|
|
||||||
taskId: Uuid;
|
|
||||||
done: boolean;
|
|
||||||
completionConditionSatisfied: boolean;
|
|
||||||
}[];
|
|
||||||
releaseApproval: {
|
|
||||||
decisionId: Uuid;
|
|
||||||
approved: boolean;
|
|
||||||
policyRevision: string;
|
|
||||||
} | null;
|
|
||||||
activeLeaseId: Uuid | null;
|
|
||||||
retry: RetryStateSnapshotV1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentSessionSnapshotV1 {
|
|
||||||
workspaceId: Uuid;
|
|
||||||
agentId: Uuid;
|
|
||||||
sessionId: Uuid;
|
|
||||||
state: 'available' | 'busy';
|
|
||||||
roles: readonly SpecialistRole[];
|
|
||||||
capabilities: readonly string[];
|
|
||||||
capacity: number;
|
|
||||||
activeLeaseCount: number;
|
|
||||||
heartbeatAt: IsoTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EligibilityExplanationV1 {
|
|
||||||
taskId: Uuid;
|
|
||||||
eligible: boolean;
|
|
||||||
readiness: ReadinessState;
|
|
||||||
reasons: readonly {
|
|
||||||
gate:
|
|
||||||
| 'status'
|
|
||||||
| 'project'
|
|
||||||
| 'mission'
|
|
||||||
| 'dependency'
|
|
||||||
| 'schedule'
|
|
||||||
| 'retry'
|
|
||||||
| 'approval'
|
|
||||||
| 'lease'
|
|
||||||
| 'capability'
|
|
||||||
| 'capacity'
|
|
||||||
| 'health';
|
|
||||||
satisfied: boolean;
|
|
||||||
code: string;
|
|
||||||
detail: string;
|
|
||||||
}[];
|
|
||||||
policyRevision: string;
|
|
||||||
evaluatedAt: IsoTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssignmentProposalDecisionV1 {
|
|
||||||
workspaceId: Uuid;
|
|
||||||
taskId: Uuid;
|
|
||||||
taskVersion: number;
|
|
||||||
targetAgentId: Uuid;
|
|
||||||
targetSessionId: Uuid;
|
|
||||||
specialistRole: SpecialistRole;
|
|
||||||
initialState: 'awaiting_approval' | 'policy_pre_authorized';
|
|
||||||
policyRevision: string;
|
|
||||||
explanation: EligibilityExplanationV1;
|
|
||||||
expiresAt: IsoTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssignmentCycleSnapshotV1 {
|
|
||||||
context: KanbanEvaluationContextV1;
|
|
||||||
tasks: readonly TaskEligibilitySnapshotV1[];
|
|
||||||
sessions: readonly AgentSessionSnapshotV1[];
|
|
||||||
workspaceFairness: Readonly<Record<Uuid, number>>;
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssignmentCycleDecisionV1 {
|
|
||||||
evaluatedTaskCount: number;
|
|
||||||
proposals: readonly AssignmentProposalDecisionV1[];
|
|
||||||
explanations: readonly EligibilityExplanationV1[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeaseExpirySnapshotV1 {
|
|
||||||
workspaceId: Uuid;
|
|
||||||
taskId: Uuid;
|
|
||||||
taskVersion: number;
|
|
||||||
leaseId: Uuid;
|
|
||||||
assignmentId: Uuid;
|
|
||||||
sessionId: Uuid;
|
|
||||||
fencingToken: FencingTokenV1;
|
|
||||||
state: 'pending_ack' | 'active';
|
|
||||||
acknowledgeBy: IsoTimestamp;
|
|
||||||
expiresAt: IsoTimestamp;
|
|
||||||
lastHeartbeatAt: IsoTimestamp | null;
|
|
||||||
retry: RetryStateSnapshotV1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeaseExpiryDecisionV1 {
|
|
||||||
leaseId: Uuid;
|
|
||||||
action: 'retain' | 'release' | 'retry' | 'quarantine' | 'exhaust';
|
|
||||||
reason: string;
|
|
||||||
nextEligibleAt: IsoTimestamp | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pure package owned by KBN-200. It receives complete immutable snapshots. */
|
|
||||||
export interface MechanicalCoordinatorDecisionEngineV1 {
|
|
||||||
evaluateAssignmentCycle(snapshot: AssignmentCycleSnapshotV1): AssignmentCycleDecisionV1;
|
|
||||||
explainEligibility(
|
|
||||||
context: KanbanEvaluationContextV1,
|
|
||||||
task: TaskEligibilitySnapshotV1,
|
|
||||||
sessions: readonly AgentSessionSnapshotV1[],
|
|
||||||
): EligibilityExplanationV1;
|
|
||||||
decideLeaseExpiry(
|
|
||||||
context: KanbanEvaluationContextV1,
|
|
||||||
lease: LeaseExpirySnapshotV1,
|
|
||||||
): LeaseExpiryDecisionV1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PersistedAssignmentV1 {
|
|
||||||
assignmentId: Uuid;
|
|
||||||
workspaceId: Uuid;
|
|
||||||
taskId: Uuid;
|
|
||||||
taskVersion: number;
|
|
||||||
targetAgentId: Uuid;
|
|
||||||
targetSessionId: Uuid;
|
|
||||||
specialistRole: SpecialistRole;
|
|
||||||
state: AssignmentStateV1;
|
|
||||||
policyRevision: string;
|
|
||||||
proposedBy: { kind: 'user' | 'agent'; id: Uuid };
|
|
||||||
reason: string;
|
|
||||||
createdAt: IsoTimestamp;
|
|
||||||
expiresAt: IsoTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TaskLeaseV1 {
|
|
||||||
leaseId: Uuid;
|
|
||||||
workspaceId: Uuid;
|
|
||||||
taskId: Uuid;
|
|
||||||
taskVersion: number;
|
|
||||||
assignmentId: Uuid;
|
|
||||||
agentId: Uuid;
|
|
||||||
sessionId: Uuid;
|
|
||||||
state: 'pending_ack' | 'active';
|
|
||||||
fencingToken: FencingTokenV1;
|
|
||||||
attempt: number;
|
|
||||||
acquiredAt: IsoTimestamp;
|
|
||||||
acknowledgeBy: IsoTimestamp;
|
|
||||||
lastHeartbeatAt: IsoTimestamp | null;
|
|
||||||
expiresAt: IsoTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceCommandBaseV1 {
|
|
||||||
context: InternalKanbanMutationContextV1;
|
|
||||||
taskId: Uuid;
|
|
||||||
expectedTaskVersion: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AcquireApprovedLeaseCommandV1 extends ServiceCommandBaseV1 {
|
|
||||||
assignmentId: Uuid;
|
|
||||||
approvalDecisionId: Uuid;
|
|
||||||
targetSessionId: Uuid;
|
|
||||||
leaseTtlSeconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeaseCommandV1 extends ServiceCommandBaseV1 {
|
|
||||||
leaseId: Uuid;
|
|
||||||
sessionId: Uuid;
|
|
||||||
fencingToken: FencingTokenV1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HeartbeatLeaseCommandV1 extends LeaseCommandV1 {
|
|
||||||
extendSeconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckpointCommandV1 extends LeaseCommandV1 {
|
|
||||||
sequence: number;
|
|
||||||
resumableSummary: string;
|
|
||||||
artifactIds: readonly Uuid[];
|
|
||||||
contextUsagePercent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubmitForReviewCommandV1 extends LeaseCommandV1 {
|
|
||||||
artifactIds: readonly Uuid[];
|
|
||||||
summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReleaseLeaseCommandV1 extends LeaseCommandV1 {
|
|
||||||
reason:
|
|
||||||
| 'worker_requested'
|
|
||||||
| 'ack_timeout'
|
|
||||||
| 'heartbeat_timeout'
|
|
||||||
| 'task_submitted'
|
|
||||||
| 'policy_revoked'
|
|
||||||
| 'shutdown';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssignmentCycleCommandV1 {
|
|
||||||
context: InternalKanbanMutationContextV1;
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExpirySweepCommandV1 {
|
|
||||||
context: InternalKanbanMutationContextV1;
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecoverCoordinatorCommandV1 {
|
|
||||||
context: InternalKanbanMutationContextV1;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CoordinatorRejectionBaseV1 {
|
|
||||||
kind: 'coordinator_rejection';
|
|
||||||
retryable: false;
|
|
||||||
requestOutcome: 'not_applied';
|
|
||||||
correlationId: Uuid;
|
|
||||||
idempotencyKey: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CoordinatorPolicyRejectionV1 =
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'WORKSPACE_MISMATCH' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & {
|
|
||||||
code: 'TASK_NOT_ELIGIBLE';
|
|
||||||
explanation: EligibilityExplanationV1;
|
|
||||||
})
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_REQUIRED' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_STALE' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_STALE' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_TARGET_MISMATCH' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'POLICY_REVISION_MISMATCH' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'ARTIFACT_WORKSPACE_MISMATCH' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_ALREADY_ACTIVE' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_FOUND' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_ACTIVE' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & {
|
|
||||||
code: 'ACK_DEADLINE_EXPIRED';
|
|
||||||
expiredAt: IsoTimestamp;
|
|
||||||
})
|
|
||||||
| (CoordinatorRejectionBaseV1 & {
|
|
||||||
code: 'FENCING_TOKEN_STALE';
|
|
||||||
currentFencingToken: FencingTokenV1;
|
|
||||||
})
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'SESSION_MISMATCH' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & {
|
|
||||||
code: 'HEARTBEAT_EXPIRED';
|
|
||||||
expiredAt: IsoTimestamp;
|
|
||||||
})
|
|
||||||
| (CoordinatorRejectionBaseV1 & {
|
|
||||||
code: 'CHECKPOINT_SEQUENCE_CONFLICT';
|
|
||||||
currentSequence: number;
|
|
||||||
})
|
|
||||||
| (CoordinatorRejectionBaseV1 & { code: 'RETRY_EXHAUSTED' })
|
|
||||||
| (CoordinatorRejectionBaseV1 & {
|
|
||||||
code: 'NON_IDEMPOTENT_RETRY_REQUIRES_ORCHESTRATOR';
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Explicit mapping to the Gateway mutation failure union; no arbitrary booleans. */
|
|
||||||
export type CoordinatorFailureV1 =
|
|
||||||
| DeliberateWriteDenialV1
|
|
||||||
| VersionConflictV1
|
|
||||||
| RetryableTransportErrorV1
|
|
||||||
| CoordinatorPolicyRejectionV1;
|
|
||||||
|
|
||||||
export interface CoordinatorSuccessV1<T> {
|
|
||||||
ok: true;
|
|
||||||
value: T;
|
|
||||||
correlationId: Uuid;
|
|
||||||
}
|
|
||||||
export interface CoordinatorFailureResultV1 {
|
|
||||||
ok: false;
|
|
||||||
failure: CoordinatorFailureV1;
|
|
||||||
}
|
|
||||||
export type CoordinatorResultV1<T> = CoordinatorSuccessV1<T> | CoordinatorFailureResultV1;
|
|
||||||
|
|
||||||
export interface ExpirySweepResultV1 {
|
|
||||||
examined: number;
|
|
||||||
released: readonly Uuid[];
|
|
||||||
retryScheduled: readonly Uuid[];
|
|
||||||
quarantined: readonly Uuid[];
|
|
||||||
exhausted: readonly Uuid[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RestartRecoveryResultV1 {
|
|
||||||
activeLeaseIds: readonly Uuid[];
|
|
||||||
expiredLeaseIds: readonly Uuid[];
|
|
||||||
pendingAssignmentIds: readonly Uuid[];
|
|
||||||
pendingOutboxEventIds: readonly Uuid[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persistence/Gateway adapter owned by KBN-210. */
|
|
||||||
export interface MechanicalCoordinatorServicePortV1 {
|
|
||||||
/** Loads immutable snapshots, invokes pure engine, and persists proposals atomically. */
|
|
||||||
runAssignmentCycle(
|
|
||||||
command: AssignmentCycleCommandV1,
|
|
||||||
): Promise<CoordinatorResultV1<{ assignments: readonly PersistedAssignmentV1[] }>>;
|
|
||||||
|
|
||||||
/** Query path loads by ID; public health observation cannot authorize mutation. */
|
|
||||||
getEligibilityExplanation(
|
|
||||||
context: KanbanEvaluationContextV1,
|
|
||||||
taskId: Uuid,
|
|
||||||
): Promise<CoordinatorResultV1<EligibilityExplanationV1>>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accepts IDs only. Implementation reloads and locks assignment + approval +
|
|
||||||
* task + target session in PostgreSQL, then verifies workspace, task version,
|
|
||||||
* target agent/session, state, expiry, policy revision, and current approval.
|
|
||||||
*/
|
|
||||||
acquireApprovedLease(
|
|
||||||
command: AcquireApprovedLeaseCommandV1,
|
|
||||||
): Promise<CoordinatorResultV1<TaskLeaseV1>>;
|
|
||||||
|
|
||||||
acknowledgeLease(command: LeaseCommandV1): Promise<CoordinatorResultV1<TaskLeaseV1>>;
|
|
||||||
heartbeatLease(command: HeartbeatLeaseCommandV1): Promise<CoordinatorResultV1<TaskLeaseV1>>;
|
|
||||||
appendCheckpoint(
|
|
||||||
command: CheckpointCommandV1,
|
|
||||||
): Promise<CoordinatorResultV1<{ checkpointId: Uuid }>>;
|
|
||||||
submitForReview(
|
|
||||||
command: SubmitForReviewCommandV1,
|
|
||||||
): Promise<CoordinatorResultV1<{ taskVersion: number; status: 'in_review' }>>;
|
|
||||||
releaseLease(command: ReleaseLeaseCommandV1): Promise<CoordinatorResultV1<{ released: true }>>;
|
|
||||||
expireAndRecover(
|
|
||||||
command: ExpirySweepCommandV1,
|
|
||||||
): Promise<CoordinatorResultV1<ExpirySweepResultV1>>;
|
|
||||||
recoverFromPostgres(
|
|
||||||
command: RecoverCoordinatorCommandV1,
|
|
||||||
): Promise<CoordinatorResultV1<RestartRecoveryResultV1>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compile-time mapping guarantee: Coordinator Gateway failures are Kanban failures or exact policy rejections. */
|
|
||||||
export function isKanbanMutationFailureV1(
|
|
||||||
failure: CoordinatorFailureV1,
|
|
||||||
): failure is KanbanMutationFailureV1 {
|
|
||||||
return (
|
|
||||||
failure.kind === 'deliberate_fail_closed_denial' ||
|
|
||||||
failure.kind === 'retryable_transport_error' ||
|
|
||||||
failure.kind === 'version_conflict'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mosaic Native Kanban — frozen recovery-posture contract v1.
|
|
||||||
* Recovery posture is configurable; SOT, write-health, Coordinator authority,
|
|
||||||
* and gate semantics are not fields and cannot be overridden.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const RECOVERY_POSTURE_CONTRACT_VERSION = '1.0.0' as const;
|
|
||||||
export const recoveryTiers = ['lite', 'standard', 'high-assurance'] as const;
|
|
||||||
export type RecoveryTier = (typeof recoveryTiers)[number];
|
|
||||||
|
|
||||||
export interface OffClusterStorageV1 {
|
|
||||||
required: true;
|
|
||||||
encrypted: true;
|
|
||||||
separateFailureDomain: true;
|
|
||||||
minimumCopies: number;
|
|
||||||
storageClass: 'encrypted-object-storage' | 'encrypted-backup-target';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecoveryPostureV1 {
|
|
||||||
contractVersion: typeof RECOVERY_POSTURE_CONTRACT_VERSION;
|
|
||||||
tier: RecoveryTier;
|
|
||||||
targetRpoMinutes: number;
|
|
||||||
targetRtoMinutes: number;
|
|
||||||
baseBackupIntervalHours: number;
|
|
||||||
/** null means WAL archival/PITR is disabled. */
|
|
||||||
walArchiveIntervalMinutes: number | null;
|
|
||||||
/** 0 means PITR is disabled. */
|
|
||||||
pitrRetentionDays: number;
|
|
||||||
restoreTestIntervalDays: number;
|
|
||||||
breakGlassDrillIntervalDays: number;
|
|
||||||
offClusterStorage: OffClusterStorageV1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const recoveryPostureDefaults: Readonly<Record<RecoveryTier, RecoveryPostureV1>> = {
|
|
||||||
lite: {
|
|
||||||
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
|
||||||
tier: 'lite',
|
|
||||||
targetRpoMinutes: 24 * 60,
|
|
||||||
targetRtoMinutes: 24 * 60,
|
|
||||||
baseBackupIntervalHours: 24,
|
|
||||||
walArchiveIntervalMinutes: null,
|
|
||||||
pitrRetentionDays: 0,
|
|
||||||
restoreTestIntervalDays: 90,
|
|
||||||
breakGlassDrillIntervalDays: 365,
|
|
||||||
offClusterStorage: {
|
|
||||||
required: true,
|
|
||||||
encrypted: true,
|
|
||||||
separateFailureDomain: true,
|
|
||||||
minimumCopies: 1,
|
|
||||||
storageClass: 'encrypted-backup-target',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
standard: {
|
|
||||||
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
|
||||||
tier: 'standard',
|
|
||||||
targetRpoMinutes: 60,
|
|
||||||
targetRtoMinutes: 8 * 60,
|
|
||||||
baseBackupIntervalHours: 24,
|
|
||||||
walArchiveIntervalMinutes: 15,
|
|
||||||
pitrRetentionDays: 14,
|
|
||||||
restoreTestIntervalDays: 90,
|
|
||||||
breakGlassDrillIntervalDays: 180,
|
|
||||||
offClusterStorage: {
|
|
||||||
required: true,
|
|
||||||
encrypted: true,
|
|
||||||
separateFailureDomain: true,
|
|
||||||
minimumCopies: 1,
|
|
||||||
storageClass: 'encrypted-object-storage',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'high-assurance': {
|
|
||||||
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
|
||||||
tier: 'high-assurance',
|
|
||||||
targetRpoMinutes: 15,
|
|
||||||
targetRtoMinutes: 4 * 60,
|
|
||||||
baseBackupIntervalHours: 24,
|
|
||||||
walArchiveIntervalMinutes: 5,
|
|
||||||
pitrRetentionDays: 35,
|
|
||||||
restoreTestIntervalDays: 30,
|
|
||||||
breakGlassDrillIntervalDays: 90,
|
|
||||||
offClusterStorage: {
|
|
||||||
required: true,
|
|
||||||
encrypted: true,
|
|
||||||
separateFailureDomain: true,
|
|
||||||
minimumCopies: 1,
|
|
||||||
storageClass: 'encrypted-object-storage',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Shape schema. Normative cross-field semantics are enforced by validateRecoveryPostureV1. */
|
|
||||||
export const recoveryPostureJsonSchemaV1 = {
|
|
||||||
$id: 'https://mosaicstack.dev/contracts/recovery-posture.v1.schema.json',
|
|
||||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: false,
|
|
||||||
required: [
|
|
||||||
'contractVersion',
|
|
||||||
'tier',
|
|
||||||
'targetRpoMinutes',
|
|
||||||
'targetRtoMinutes',
|
|
||||||
'baseBackupIntervalHours',
|
|
||||||
'walArchiveIntervalMinutes',
|
|
||||||
'pitrRetentionDays',
|
|
||||||
'restoreTestIntervalDays',
|
|
||||||
'breakGlassDrillIntervalDays',
|
|
||||||
'offClusterStorage',
|
|
||||||
],
|
|
||||||
properties: {
|
|
||||||
contractVersion: { const: RECOVERY_POSTURE_CONTRACT_VERSION },
|
|
||||||
tier: { enum: recoveryTiers },
|
|
||||||
targetRpoMinutes: { type: 'integer', minimum: 1 },
|
|
||||||
targetRtoMinutes: { type: 'integer', minimum: 1 },
|
|
||||||
baseBackupIntervalHours: { type: 'integer', minimum: 1 },
|
|
||||||
walArchiveIntervalMinutes: {
|
|
||||||
anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }],
|
|
||||||
},
|
|
||||||
pitrRetentionDays: { type: 'integer', minimum: 0 },
|
|
||||||
restoreTestIntervalDays: { type: 'integer', minimum: 1 },
|
|
||||||
breakGlassDrillIntervalDays: { type: 'integer', minimum: 1 },
|
|
||||||
offClusterStorage: {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: false,
|
|
||||||
required: ['required', 'encrypted', 'separateFailureDomain', 'minimumCopies', 'storageClass'],
|
|
||||||
properties: {
|
|
||||||
required: { const: true },
|
|
||||||
encrypted: { const: true },
|
|
||||||
separateFailureDomain: { const: true },
|
|
||||||
minimumCopies: { type: 'integer', minimum: 1 },
|
|
||||||
storageClass: {
|
|
||||||
enum: ['encrypted-object-storage', 'encrypted-backup-target'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const recoveryValidationCodes = [
|
|
||||||
'INVALID_SHAPE',
|
|
||||||
'UNKNOWN_FIELD',
|
|
||||||
'PITR_REQUIRES_WAL',
|
|
||||||
'WAL_REQUIRES_PITR',
|
|
||||||
'RPO_BETTER_THAN_MECHANISM',
|
|
||||||
'OFF_CLUSTER_REQUIRED',
|
|
||||||
'HIGH_ASSURANCE_WEAKENED',
|
|
||||||
] as const;
|
|
||||||
export type RecoveryValidationCode = (typeof recoveryValidationCodes)[number];
|
|
||||||
|
|
||||||
export interface RecoveryValidationIssueV1 {
|
|
||||||
code: RecoveryValidationCode;
|
|
||||||
path: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
export type RecoveryValidationResultV1 =
|
|
||||||
| { ok: true; value: RecoveryPostureV1 }
|
|
||||||
| { ok: false; issues: RecoveryValidationIssueV1[] };
|
|
||||||
|
|
||||||
const topLevelFields = new Set([
|
|
||||||
'contractVersion',
|
|
||||||
'tier',
|
|
||||||
'targetRpoMinutes',
|
|
||||||
'targetRtoMinutes',
|
|
||||||
'baseBackupIntervalHours',
|
|
||||||
'walArchiveIntervalMinutes',
|
|
||||||
'pitrRetentionDays',
|
|
||||||
'restoreTestIntervalDays',
|
|
||||||
'breakGlassDrillIntervalDays',
|
|
||||||
'offClusterStorage',
|
|
||||||
]);
|
|
||||||
const storageFields = new Set([
|
|
||||||
'required',
|
|
||||||
'encrypted',
|
|
||||||
'separateFailureDomain',
|
|
||||||
'minimumCopies',
|
|
||||||
'storageClass',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
function isPositiveInteger(value: unknown): value is number {
|
|
||||||
return Number.isInteger(value) && Number(value) > 0;
|
|
||||||
}
|
|
||||||
function isNonnegativeInteger(value: unknown): value is number {
|
|
||||||
return Number.isInteger(value) && Number(value) >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normative parser/refinement. Deployment code MUST call this function (or a
|
|
||||||
* byte-for-byte behaviorally equivalent generated validator), not JSON Schema
|
|
||||||
* shape validation alone.
|
|
||||||
*/
|
|
||||||
export function validateRecoveryPostureV1(input: unknown): RecoveryValidationResultV1 {
|
|
||||||
const issues: RecoveryValidationIssueV1[] = [];
|
|
||||||
if (!isRecord(input)) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
issues: [{ code: 'INVALID_SHAPE', path: '$', message: 'posture must be an object' }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of Object.keys(input)) {
|
|
||||||
if (!topLevelFields.has(key)) {
|
|
||||||
issues.push({ code: 'UNKNOWN_FIELD', path: `$.${key}`, message: 'unknown field' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tier = input['tier'];
|
|
||||||
const storage = input['offClusterStorage'];
|
|
||||||
const integerFields = [
|
|
||||||
'targetRpoMinutes',
|
|
||||||
'targetRtoMinutes',
|
|
||||||
'baseBackupIntervalHours',
|
|
||||||
'restoreTestIntervalDays',
|
|
||||||
'breakGlassDrillIntervalDays',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
if (input['contractVersion'] !== RECOVERY_POSTURE_CONTRACT_VERSION) {
|
|
||||||
issues.push({
|
|
||||||
code: 'INVALID_SHAPE',
|
|
||||||
path: '$.contractVersion',
|
|
||||||
message: `must equal ${RECOVERY_POSTURE_CONTRACT_VERSION}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!recoveryTiers.includes(tier as RecoveryTier)) {
|
|
||||||
issues.push({ code: 'INVALID_SHAPE', path: '$.tier', message: 'unknown recovery tier' });
|
|
||||||
}
|
|
||||||
for (const field of integerFields) {
|
|
||||||
if (!isPositiveInteger(input[field])) {
|
|
||||||
issues.push({
|
|
||||||
code: 'INVALID_SHAPE',
|
|
||||||
path: `$.${field}`,
|
|
||||||
message: 'must be a positive integer',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!isNonnegativeInteger(input['pitrRetentionDays'])) {
|
|
||||||
issues.push({
|
|
||||||
code: 'INVALID_SHAPE',
|
|
||||||
path: '$.pitrRetentionDays',
|
|
||||||
message: 'must be a nonnegative integer',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
input['walArchiveIntervalMinutes'] !== null &&
|
|
||||||
!isPositiveInteger(input['walArchiveIntervalMinutes'])
|
|
||||||
) {
|
|
||||||
issues.push({
|
|
||||||
code: 'INVALID_SHAPE',
|
|
||||||
path: '$.walArchiveIntervalMinutes',
|
|
||||||
message: 'must be null or a positive integer',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRecord(storage)) {
|
|
||||||
issues.push({
|
|
||||||
code: 'INVALID_SHAPE',
|
|
||||||
path: '$.offClusterStorage',
|
|
||||||
message: 'must be an object',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
for (const key of Object.keys(storage)) {
|
|
||||||
if (!storageFields.has(key)) {
|
|
||||||
issues.push({
|
|
||||||
code: 'UNKNOWN_FIELD',
|
|
||||||
path: `$.offClusterStorage.${key}`,
|
|
||||||
message: 'unknown field',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
storage['required'] !== true ||
|
|
||||||
storage['encrypted'] !== true ||
|
|
||||||
storage['separateFailureDomain'] !== true
|
|
||||||
) {
|
|
||||||
issues.push({
|
|
||||||
code: 'OFF_CLUSTER_REQUIRED',
|
|
||||||
path: '$.offClusterStorage',
|
|
||||||
message: 'storage must be required, encrypted, and in a separate failure domain',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!isPositiveInteger(storage['minimumCopies'])) {
|
|
||||||
issues.push({
|
|
||||||
code: 'INVALID_SHAPE',
|
|
||||||
path: '$.offClusterStorage.minimumCopies',
|
|
||||||
message: 'must be a positive integer',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
storage['storageClass'] !== 'encrypted-object-storage' &&
|
|
||||||
storage['storageClass'] !== 'encrypted-backup-target'
|
|
||||||
) {
|
|
||||||
issues.push({
|
|
||||||
code: 'INVALID_SHAPE',
|
|
||||||
path: '$.offClusterStorage.storageClass',
|
|
||||||
message: 'unsupported storage class',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wal = input['walArchiveIntervalMinutes'];
|
|
||||||
const pitr = input['pitrRetentionDays'];
|
|
||||||
if (pitr !== 0 && wal === null) {
|
|
||||||
issues.push({
|
|
||||||
code: 'PITR_REQUIRES_WAL',
|
|
||||||
path: '$.pitrRetentionDays',
|
|
||||||
message: 'PITR retention requires WAL archival',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (wal !== null && pitr === 0) {
|
|
||||||
issues.push({
|
|
||||||
code: 'WAL_REQUIRES_PITR',
|
|
||||||
path: '$.walArchiveIntervalMinutes',
|
|
||||||
message: 'WAL archival requires positive PITR retention',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
isPositiveInteger(input['targetRpoMinutes']) &&
|
|
||||||
isPositiveInteger(input['baseBackupIntervalHours']) &&
|
|
||||||
(wal === null || isPositiveInteger(wal))
|
|
||||||
) {
|
|
||||||
const mechanismMinutes = wal === null ? input['baseBackupIntervalHours'] * 60 : wal;
|
|
||||||
if (mechanismMinutes > input['targetRpoMinutes']) {
|
|
||||||
issues.push({
|
|
||||||
code: 'RPO_BETTER_THAN_MECHANISM',
|
|
||||||
path: '$.targetRpoMinutes',
|
|
||||||
message: `configured mechanism can only support ${mechanismMinutes} minutes`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tier === 'high-assurance') {
|
|
||||||
const weakened =
|
|
||||||
!isPositiveInteger(input['targetRpoMinutes']) ||
|
|
||||||
input['targetRpoMinutes'] > 15 ||
|
|
||||||
!isPositiveInteger(input['targetRtoMinutes']) ||
|
|
||||||
input['targetRtoMinutes'] > 4 * 60 ||
|
|
||||||
!isPositiveInteger(input['baseBackupIntervalHours']) ||
|
|
||||||
input['baseBackupIntervalHours'] > 24 ||
|
|
||||||
!isPositiveInteger(wal) ||
|
|
||||||
wal > 5 ||
|
|
||||||
!isNonnegativeInteger(pitr) ||
|
|
||||||
pitr < 35 ||
|
|
||||||
!isPositiveInteger(input['restoreTestIntervalDays']) ||
|
|
||||||
input['restoreTestIntervalDays'] > 30 ||
|
|
||||||
!isPositiveInteger(input['breakGlassDrillIntervalDays']) ||
|
|
||||||
input['breakGlassDrillIntervalDays'] > 90;
|
|
||||||
if (weakened) {
|
|
||||||
issues.push({
|
|
||||||
code: 'HIGH_ASSURANCE_WEAKENED',
|
|
||||||
path: '$',
|
|
||||||
message: 'high-assurance posture may be strengthened but not weakened',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (issues.length > 0) return { ok: false, issues };
|
|
||||||
return { ok: true, value: input as unknown as RecoveryPostureV1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecoveryPostureOverrideAuditV1 {
|
|
||||||
actorId: string;
|
|
||||||
reason: string;
|
|
||||||
effectiveAt: string;
|
|
||||||
policyRevision: string;
|
|
||||||
previous: RecoveryPostureV1;
|
|
||||||
next: RecoveryPostureV1;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"noEmit": true,
|
|
||||||
"incremental": false,
|
|
||||||
"declaration": false,
|
|
||||||
"declarationMap": false,
|
|
||||||
"sourceMap": false,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"drizzle-orm": ["../../packages/db/node_modules/drizzle-orm/index.d.ts"],
|
|
||||||
"drizzle-orm/pg-core": ["../../packages/db/node_modules/drizzle-orm/pg-core/index.d.ts"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["contracts/*.ts"]
|
|
||||||
}
|
|
||||||
@@ -124,7 +124,7 @@ paths:
|
|||||||
{
|
{
|
||||||
summary: Submit Mos handoff,
|
summary: Submit Mos handoff,
|
||||||
parameters: [{ $ref: '#/components/parameters/correlation' }],
|
parameters: [{ $ref: '#/components/parameters/correlation' }],
|
||||||
requestBody: { $ref: '#/components/requestBodies/Handoff' },
|
requestBody: { $ref: '#/components/requestBodies/MosHandoff' },
|
||||||
responses: { '200': { description: Receipt } },
|
responses: { '200': { description: Receipt } },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ components:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
Handoff:
|
MosHandoff:
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
content:
|
content:
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
VERDICT: GO
|
|
||||||
|
|
||||||
# Native Kanban/SOT canon independent re-review 2
|
|
||||||
|
|
||||||
Independent read-only re-review of the complete updated staged canon. Prior proposal-audit blocker is closed; no KCR-001–016 regression or new blocker found.
|
|
||||||
|
|
||||||
## Prior blocker closure
|
|
||||||
|
|
||||||
- `contracts/kanban-schema.v1.ts:836-837` declares the required unique `task_events(workspace_id,id)` key before proposal declaration.
|
|
||||||
- `contracts/kanban-schema.v1.ts:885-894` adds both composite proposal audit FKs—submission and accepted-command event—to that exact workspace-aware key with `RESTRICT`.
|
|
||||||
- Declaration/migration order is executable and explicit in `SHARED-CONTRACT.md:79-91`: events/key first, proposal table second, both FKs third/fourth, then command enablement. This avoids forward-reference/circular-DDL ambiguity.
|
|
||||||
- Submission/acceptance semantics are frozen in `SHARED-CONTRACT.md:87-91`: preallocate proposal ID; create exact `change_proposal.submitted` event and proposal in one transaction; on acceptance lock proposal/target, execute the normal command, and bind only a same-workspace/target event with submission causation and `payload.changeProposalId` equal to the locked proposal.
|
|
||||||
- Required missing, foreign-workspace, unrelated-proposal, unrelated-target, and unrelated-command negatives are explicit in `REQUIREMENTS.md` REQ-SOT-004 and `SHARED-CONTRACT.md:121`; KBN-100/110/140 own migration, service, and integration evidence.
|
|
||||||
|
|
||||||
## KCR closure matrix
|
|
||||||
|
|
||||||
| KCR | Status |
|
|
||||||
| ------------------------------------------------------ | ------ |
|
|
||||||
| 001 health/proof | CLOSED |
|
|
||||||
| 002 error discrimination | CLOSED |
|
|
||||||
| 003 approval/assignment binding | CLOSED |
|
|
||||||
| 004 monotonic fencing/composites | CLOSED |
|
|
||||||
| 005 tenant-safe relations | CLOSED |
|
|
||||||
| 006 outage proposal persistence/commands/audit binding | CLOSED |
|
|
||||||
| 007 dependency/API freeze sequencing | CLOSED |
|
|
||||||
| 008 concrete N-1 map | CLOSED |
|
|
||||||
| 009 dependency uniqueness | CLOSED |
|
|
||||||
| 010 project congruence | CLOSED |
|
|
||||||
| 011 immutable audit retention | CLOSED |
|
|
||||||
| 012 retry/quarantine/vocabulary | CLOSED |
|
|
||||||
| 013 archive/tags target semantics | CLOSED |
|
|
||||||
| 014 recovery validator/owner slice | CLOSED |
|
|
||||||
| 015 pure Coordinator split | CLOSED |
|
|
||||||
| 016 health code/state pairing | CLOSED |
|
|
||||||
|
|
||||||
Fixed invariants remain consistent: PostgreSQL is sole writable SOT; writes require transaction-local proof and fail closed; exports never import sources; notes are attributable proposals only; Coordinator has no scope/gate/certify/merge authority; Certifier has no merge authority.
|
|
||||||
|
|
||||||
## Reproducible validation evidence
|
|
||||||
|
|
||||||
Executed read-only with current-stack config/toolchain `/src/mosaic-mono-v1`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
./node_modules/.bin/prettier --config /src/mosaic-mono-v1/.prettierrc --check <all 9 publication artifacts>
|
|
||||||
PASS: All matched files use Prettier code style.
|
|
||||||
|
|
||||||
strict TypeScript --noEmit --strict --skipLibCheck --target ES2022 --module NodeNext --moduleResolution NodeNext <four contract copies with current Drizzle node_modules resolution>
|
|
||||||
PASS
|
|
||||||
|
|
||||||
cascade/TODO/TBD/stale-hold grep plus composite-FK/semantic-marker invariant checks
|
|
||||||
PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
The TypeScript check used a disposable copy under `/home/hermes/agent-work` solely to provide external-file NodeNext dependency resolution; the reviewed staging artifacts were not modified.
|
|
||||||
|
|
||||||
## Residual findings
|
|
||||||
|
|
||||||
None blocking. Implementation must execute the frozen KBN-100/KBN-110/KBN-140 proposal-event-chain tests and SecReview evidence before feature release, as already required by the canon.
|
|
||||||
|
|
||||||
No artifact source repository, branch, PR, or provider state was modified.
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
# Independent Review — Native Kanban/SOT Canon
|
|
||||||
|
|
||||||
**Reviewer:** `enhance-sol` (independent of author `planner-sol`)
|
|
||||||
**Date:** 2026-07-13
|
|
||||||
**Review mode:** design/contract only; read-only against the staged canon
|
|
||||||
**Source plan:** `/home/hermes/agent-work/planning/mosaic-native-kanban-sot-plan.md` (`sha256:96ea4fb91436ec9a53f371d27276e27f62ecf817662599ff9152df0db55296e5`)
|
|
||||||
**Canon reviewed:** every listed artifact under `/home/hermes/agent-work/planning/kanban-canon/`, including the four TypeScript contracts; the author scratchpad was also read as validation context.
|
|
||||||
|
|
||||||
## Executive verdict
|
|
||||||
|
|
||||||
# NO-GO
|
|
||||||
|
|
||||||
The canon is not freeze-ready. I found **8 BLOCKERs**, **7 MAJORs**, and **1 MINOR**. The prose preserves the ratified authority model well, but the frozen types/schema leave concrete fail-closed, approval, fencing, tenant, outage-proposal, migration, and parallelization gaps. Those gaps would force implementation lanes either to invent contract semantics or to ship paths that violate fixed invariants.
|
|
||||||
|
|
||||||
### Blocking findings
|
|
||||||
|
|
||||||
1. Health/write authorization can be represented as contradictory, stale, or caller-asserted state.
|
|
||||||
2. Coordinator failures collapse authoritative denial, unknown transport outcome, and version conflict into one permissive shape.
|
|
||||||
3. Assignment proposals and approval proofs have no authoritative relational binding; lease acquisition accepts a forgeable proof DTO.
|
|
||||||
4. Fencing uniqueness is present, but monotonic fencing and same-task lease/checkpoint binding are not.
|
|
||||||
5. Workspace-safe accountable-owner, assignment-principal, and evidence/artifact relationships are not frozen.
|
|
||||||
6. Attributable post-recovery proposals have neither a canonical table nor command contract.
|
|
||||||
7. The slice graph starts schema/UI work before prerequisite threat and exact API/DTO freezes and contradicts coder4 lane order.
|
|
||||||
8. P0 claims a migration map while publishing only generic rules; the concrete N-1 transition from current `origin/main` is absent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
### KCR-001 — BLOCKER — “Healthy” is not a proof and can be contradictory or stale
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/health-state.v1.ts:21-31` — `KanbanHealthResponseV1` permits every combination of `state`, `readHealthProven`, and `writeHealthProven`.
|
|
||||||
- `contracts/mechanical-coordinator.v1.ts:40-49` — `CoordinatorContextV1` accepts a caller-supplied `healthState` enum only.
|
|
||||||
- `contracts/mechanical-coordinator.v1.ts:255-293` — every Coordinator operation, including mutating operations, accepts that context.
|
|
||||||
- `SHARED-CONTRACT.md:171-184` — mutations are allowed only after live PostgreSQL read/write probes.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
Fixed invariant 2 / `REQ-SOT-002`: mutations must fail closed unless write health is positively proven. The current type permits `{ state: 'healthy', writeHealthProven: false }`, and the Coordinator mutation boundary can be invoked with a stale or fabricated `{ healthState: 'healthy' }`. A Valkey/client-derived enum could therefore be mistaken for write authorization.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
1. Make `KanbanHealthResponseV1` a discriminated union with only these legal combinations: `healthy => read=true/write=true`, `read-only-degraded => read=true/write=false`, and `write-unavailable => read=false/write=false`.
|
|
||||||
2. Do not accept write authority from a public DTO. Require Gateway/domain code to obtain and revalidate a fresh internal PostgreSQL write-health proof at mutation time (including `checkedAt`, bounded validity/policy revision, and transaction-local enforcement).
|
|
||||||
3. Split pure evaluation context from mutation context; mutation methods must accept only an unforgeable/internal healthy context or perform the probe themselves.
|
|
||||||
4. Add negative contract tests for contradictory state, expired proof, Valkey-only liveness, and caller-forged `healthy`.
|
|
||||||
|
|
||||||
### KCR-002 — BLOCKER — Coordinator error shape can conflate denial, unknown outcome, and conflict
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/mechanical-coordinator.v1.ts:184-216` — one `CoordinatorFailureV1` allows every code to pair with arbitrary `retryable` and either `requestOutcome` value.
|
|
||||||
- `contracts/health-state.v1.ts:51-106` — the Gateway health contract correctly distinguishes deliberate denial, transport uncertainty, and version conflict.
|
|
||||||
- `SHARED-CONTRACT.md:177-216` — frozen client semantics require those cases not to be conflated.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
Charter E and `REQ-SOT-002`. The current Coordinator result can legally encode `WRITE_HEALTH_UNPROVEN` as `retryable: true, requestOutcome: 'unknown'`, or `VERSION_CONFLICT` as retryable. That permits blind retry or a false “unknown” outcome after an authoritative fail-closed denial.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Replace `CoordinatorFailureV1` with a discriminated union keyed by code/kind:
|
|
||||||
|
|
||||||
- deliberate health denial: `not_applied`, `retryable:false`;
|
|
||||||
- version conflict: `not_applied`, `retryable:false`, current version;
|
|
||||||
- stale fence/session/eligibility/approval failures: exact non-retry semantics;
|
|
||||||
- transport failure: a separate `retryable_transport_error`, `unknown`, same idempotency key.
|
|
||||||
|
|
||||||
Reuse or map explicitly to `KanbanMutationFailureV1`, and add exhaustive client tests proving 503 authoritative bodies, 502/504/timeouts, and 409 cannot cross-map.
|
|
||||||
|
|
||||||
### KCR-003 — BLOCKER — Approval proof is forgeable and is not linked to the persisted proposal
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/mechanical-coordinator.v1.ts:107-137` — proposal and approval DTOs.
|
|
||||||
- `contracts/mechanical-coordinator.v1.ts:265-270` — `acquireApprovedLease` accepts the entire `ApprovalProofV1` by value.
|
|
||||||
- `contracts/kanban-schema.v1.ts:650-688` — `task_assignments` has no proposal expiry, task version, session binding, or proposal/approval FK.
|
|
||||||
- `contracts/kanban-schema.v1.ts:823-863` — `approval_decisions` can target only a task or mission and has no proposal/assignment relation.
|
|
||||||
- `SHARED-CONTRACT.md:128-137` — lease acquisition requires authoritative approval under the exact policy revision.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
Fixed invariant 5 and `REQ-COORD-002/003`. A caller can construct an `ApprovalProofV1`; the schema cannot prove that it belongs to the proposal, workspace, task version, agent/session, unexpired policy revision, or still-current approval. The DTO state vocabulary (`awaiting_approval | policy_pre_authorized`) also does not map directly to the persisted assignment states (`proposed | approved | ...`).
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Persist one authoritative proposal/assignment identity with task version, target agent/session, expiry, state, and policy revision. Add a workspace-aware approval relation to that identity. Change lease acquisition to accept IDs, then reload and lock proposal + approval + task inside PostgreSQL and verify workspace, current version, target session, state, expiry, and policy revision before creating the lease. Freeze one state vocabulary across schema and DTOs.
|
|
||||||
|
|
||||||
### KCR-004 — BLOCKER — Fencing is unique but not monotonically increasing; relational binding is incomplete
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/kanban-schema.v1.ts:694-743` — `task_leases` has positive/unique fencing tokens but no monotonic per-task counter.
|
|
||||||
- `contracts/kanban-schema.v1.ts:748-784` — checkpoints independently carry task, lease, and fencing token.
|
|
||||||
- `contracts/kanban-schema.v1.ts:905-910` — token equality is deferred to prose; same-task lease binding is not stated.
|
|
||||||
- `contracts/mechanical-coordinator.v1.ts:140-175` — worker commands depend on fencing safety.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
Fixed invariant 12 / `REQ-COORD-003`. Uniqueness permits token 10 followed by token 9. A lease can reference assignment A while naming task B in the same workspace, and a checkpoint can reference lease A while naming task B. `bigint(..., { mode: 'number' })` also eventually loses integer precision in JavaScript.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Add a durable per-task fencing counter (or equivalent PostgreSQL sequence row) incremented atomically under task lock and use the returned value for every new lease. Add workspace-aware composite constraints tying lease to its exact task+assignment and checkpoint to exact task+lease+fence. Use bigint-safe representation (`bigint`/serialized decimal), and test monotonicity, concurrent claims, stale lower tokens, and mismatched same-workspace IDs.
|
|
||||||
|
|
||||||
### KCR-005 — BLOCKER — Hard tenant boundary is not frozen for several polymorphic relationships
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/kanban-schema.v1.ts:315-318` and `475-478` — project/task accountable owners are unvalidated `(kind, text id)` pairs.
|
|
||||||
- `contracts/kanban-schema.v1.ts:659-663` — assignment principals are unvalidated `(kind, text id)` pairs.
|
|
||||||
- `contracts/kanban-schema.v1.ts:758` and `841` — checkpoint/evidence artifact relationships are JSON arrays without workspace-aware FKs.
|
|
||||||
- `SHARED-CONTRACT.md:89-96` — only selected polymorphic checks are delegated to domain transactions; owner/principal/evidence checks are not included.
|
|
||||||
- `REQUIREMENTS.md:101-108` — every relationship must reject cross-workspace IDs.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
Fixed invariant 7 / `REQ-TEN-001` and `REQ-ID-001`. The frozen schema can name a team or agent from another workspace as owner/assignee, and can embed foreign-workspace artifact IDs in checkpoint or approval evidence arrays. A global user ID is also insufficient without active workspace membership validation.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Use workspace-aware owner/assignment join tables or separate nullable user/team/agent columns with exactly-one checks and composite FKs where possible. Model checkpoint/evidence artifact links as workspace-scoped join rows, or freeze explicit transaction checks for every ID. Require active workspace membership for user principals and workspace-agent/session consistency for agent principals. Add DB/repository/API/Coordinator cross-workspace negative tests without existence oracles.
|
|
||||||
|
|
||||||
### KCR-006 — BLOCKER — Post-recovery outage proposals have no canonical persistence or command surface
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `REQUIREMENTS.md:93-99` — proposal submission and authorized accept/reject are required.
|
|
||||||
- `SHARED-CONTRACT.md:26-29` — outage notes may return only as authenticated proposals.
|
|
||||||
- `SHARED-CONTRACT.md:243-267` — the thin command/query contract contains no proposal submit/get/accept/reject operations.
|
|
||||||
- `contracts/kanban-schema.v1.ts:1-916` — no proposal table captures proposed command, target/version, attribution, lifecycle, or decision.
|
|
||||||
- `TASKS.md:99-108` — KBN-110 does not own an outage-proposal command path.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
Fixed invariant 4 / `REQ-SOT-004`. An implementation lane would have to invent storage or misuse artifacts/approval gates. Either path risks silently applying an outage note or creating shadow state.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Add a workspace-scoped `change_proposals`/`outage_proposals` contract with authenticated proposer, source note digest, target aggregate, expected version, proposed typed command/payload, pending/accepted/rejected state, decision actor/reason/time, idempotency key, and audit linkage. Add explicit submit/query/accept/reject Gateway commands. Acceptance must execute the normal command in a healthy transaction; a proposal itself can never claim, order, satisfy a gate, or mutate the target.
|
|
||||||
|
|
||||||
### KCR-007 — BLOCKER — Parallel slice ordering is not freeze-safe and contains a direct lane-order contradiction
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `TASKS.md:43-60` — dependency graph makes KBN-010 and KBN-100 siblings.
|
|
||||||
- `TASKS.md:88-97` — KBN-100 nevertheless depends on KBN-010 threat findings that alter constraints.
|
|
||||||
- `SHARED-CONTRACT.md:243` and `INDEX.md:44-50` — exact route names/DTO placement remain unresolved.
|
|
||||||
- `TASKS.md:110-130` — KBN-120/130 depend on a frozen endpoint/DTO contract, while mocks may begin before KBN-110 lands.
|
|
||||||
- `TASKS.md:145-153` — KBN-200 says lane-serial after KBN-120.
|
|
||||||
- `TASKS.md:248-254` — wave table runs KBN-200 before KBN-120.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
Charter C and the mandatory freeze-before-parallelize gate. Schema can begin before tenant/threat findings are complete; web/CLI consumers have only semantic operations, not exact DTO/endpoint contracts; coder4 has two opposite legal orders. This does not create same-file edits immediately, but it guarantees contract invention or rework across active lanes.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
1. Make KBN-010 (or an explicit constraint-impact gate from it) a completed prerequisite of KBN-100.
|
|
||||||
2. Add a small serialized KBN-105 endpoint/DTO/endpoint-registry freeze, with exact request/response/error DTOs, before KBN-120 and KBN-130 implementation.
|
|
||||||
3. Choose one coder4 lane order and use it consistently in slice text, graph, and wave table.
|
|
||||||
4. Name the exact MCP-owned files or assign their Gateway changes to coder3 before coder4 starts.
|
|
||||||
|
|
||||||
### KCR-008 — BLOCKER — Claimed P0 migration map is absent; concrete N-1 hazards remain unresolved
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `MISSION-MANIFEST.md:153-157` — P0 says to publish a migration map and states the build hold is lifted at line 3.
|
|
||||||
- `SHARED-CONTRACT.md:101-121` — only generic expand/backfill/contract rules are supplied.
|
|
||||||
- `contracts/kanban-schema.v1.ts:1-916` — target-state declarations reuse live table names and make target fields required.
|
|
||||||
- Current foundation evidence: `origin/main:packages/db/src/schema.ts:120-301` has no workspace keys, nullable project/mission links, legacy text status vocabularies, `tasks.tags`, `tasks.assignee`, `tasks.due_date`, mission JSON milestones/config, `mission_tasks.status`, and legacy agent fields.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
Charter D / `REQ-MIG-001` and the P0 exit claim. The generic rule is correct, but coder2 lacks the required field-by-field transition map. A direct Drizzle reconciliation could attempt type narrowing/status conversion, add required workspace/project/owner columns too early, or drop legacy columns before N-1 readers and writers are retired.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Publish a concrete current-main delta map before lifting the hold. For each existing table/column, specify expand, backfill, compatibility read/write, switch, and contract release. At minimum cover:
|
|
||||||
|
|
||||||
- nullable-first `workspace_id`, required project/owner fields, and workspace backfill;
|
|
||||||
- legacy task/project/mission status aliases or shadow columns before v1 emission;
|
|
||||||
- `mission_tasks.status` read retirement and write-source prohibition;
|
|
||||||
- mapping/retention for tags, assignee, due date, mission description/config/milestones, and agent fields;
|
|
||||||
- current milestone circular FK ordering;
|
|
||||||
- empty, production-shape, partial-resume, and rollback/downgrade tests already named in §4.
|
|
||||||
|
|
||||||
Explicitly require legacy columns to remain in the unified Drizzle declaration during the expand/N-1 window.
|
|
||||||
|
|
||||||
### KCR-009 — MAJOR — Dependency uniqueness permits parallel duplicate edges
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/kanban-schema.v1.ts:561-567` — unique key includes `dependencyType`.
|
|
||||||
- `SHARED-CONTRACT.md:47-49` — calls for a unique directed edge.
|
|
||||||
- `REQUIREMENTS.md:142-149` — duplicate edge attempts must fail.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
`REQ-DEP-001`. The same predecessor/successor pair can be inserted three times, once per dependency type. That is not a unique directed edge and complicates readiness semantics.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Make `(workspace_id, predecessor_task_id, successor_task_id)` unique independent of type, or explicitly redefine the requirement as one edge per type and freeze deterministic multi-edge completion semantics. The source plan says unique directed edge, so the former is the minimal faithful fix.
|
|
||||||
|
|
||||||
### KCR-010 — MAJOR — Same-workspace planning relationships can contradict the project hierarchy
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/kanban-schema.v1.ts:325` — `projects.currentMilestoneId` has no FK in the declaration.
|
|
||||||
- `contracts/kanban-schema.v1.ts:427-448` — mission/milestone association checks workspace but not common project.
|
|
||||||
- `contracts/kanban-schema.v1.ts:490-516` — a task’s project, mission, milestone, and parent only need share a workspace, not a project.
|
|
||||||
- `contracts/kanban-schema.v1.ts:905-908` — only current milestone is mentioned as a deferred invariant.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
`REQ-PLAN-001` and schema correctness. A task in project A can point to a mission/milestone/parent task from project B in the same workspace. A mission can associate a milestone from another project despite having one required project.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Add project-congruent composite keys/FKs (or freeze mandatory transaction checks) for task→mission, task→milestone, task→parent, mission→milestone, and project→current milestone. Add same-workspace/same-project negative tests.
|
|
||||||
|
|
||||||
### KCR-011 — MAJOR — Immutable/append-only records can be erased by parent cascades
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/kanban-schema.v1.ts:798-818` — `task_events` is described as append-only but remains under a workspace cascade.
|
|
||||||
- `contracts/kanban-schema.v1.ts:911` — only application-role UPDATE/DELETE privilege removal is stated.
|
|
||||||
- Numerous canonical relationships use `onDelete('cascade')`, including workspace roots and artifact/checkpoint/event owners.
|
|
||||||
- `REQUIREMENTS.md:41-43` and `154-170` — audit must be append-only, attributable, and reconstructable.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
`REQ-AUD-001`. Revoking direct DELETE on `task_events` does not prevent a parent delete from cascading into the audit log. Checkpoints and immutable artifacts also lack explicit append-only privilege/retention semantics.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Use lifecycle/archive states and `RESTRICT` for canonical parent deletion during normal operation. Freeze a separate, audited retention/break-glass purge procedure. Apply INSERT/SELECT-only or equivalent immutability controls to task events, checkpoints, and immutable artifacts, and test that parent deletion cannot silently erase them.
|
|
||||||
|
|
||||||
### KCR-012 — MAJOR — Coordinator persistence lacks durable quarantine/retry state and DTO/schema alignment
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/mechanical-coordinator.v1.ts:239-244` — expiry returns `quarantined` IDs.
|
|
||||||
- `contracts/kanban-schema.v1.ts:457-490` — task has only untyped `retryPolicy` metadata and no quarantine/execution disposition.
|
|
||||||
- `contracts/mechanical-coordinator.v1.ts:173` — `evidenceIds` has no corresponding evidence table/type; schema has artifacts.
|
|
||||||
- `contracts/kanban-schema.v1.ts:479`, `663`, and agent role JSON — specialist roles are free text despite the frozen role vocabulary in `mechanical-coordinator.v1.ts:19-29`.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
`REQ-COORD-004` and internal consistency. PostgreSQL cannot deterministically reconstruct why/when a task was quarantined, its bounded retry state, or which typed evidence was submitted. Free-text roles allow the schema and engine to disagree.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Freeze a durable execution/retry/quarantine record (attempt count, next eligibility, terminal reason, actor/policy, timestamps, version) or typed task columns with events. Align `evidenceIds` to artifact IDs or add a real evidence entity. Use one specialist-role enum/check across tasks, assignments, agents/sessions, DTOs, and Coordinator.
|
|
||||||
|
|
||||||
### KCR-013 — MAJOR — Thin MVP promises task archive and tag filtering without target-state semantics
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `REQUIREMENTS.md:182-193` — users must archive tasks and filter by tags.
|
|
||||||
- `SHARED-CONTRACT.md:252-267` — mutations include cancel but not archive task.
|
|
||||||
- `contracts/kanban-schema.v1.ts:457-490` — no task archive field and no typed tags field/table.
|
|
||||||
- Current `origin/main` already has `tasks.tags`, making omission from the target declaration a migration-loss hazard.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
`REQ-UI-001/002` and internal acceptance consistency. “Archive” cannot be implemented without inventing whether it means cancelled, hidden, or soft-deleted; tag filtering has no frozen storage/query contract.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Either remove task archive/tag acceptance from P1, or add explicit non-lifecycle archival semantics (`archived_at/by/reason`) and a workspace-safe tags model/query contract. Preserve/migrate the current tags column until the selected model is live.
|
|
||||||
|
|
||||||
### KCR-014 — MAJOR — Recovery contract states critical rules only in comments and has no owning implementation slice
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/recovery-posture.v1.ts:97-147` — exported JSON Schema validates only local field shapes.
|
|
||||||
- `contracts/recovery-posture.v1.ts:150-156` — PITR/WAL, effective RPO, off-cluster, high-assurance minima, and audit rules are comments only.
|
|
||||||
- `REQUIREMENTS.md:270-277` — parser rejection of impossible combinations is acceptance-critical.
|
|
||||||
- `TASKS.md:75-244` — no bounded slice owns recovery config parsing, override audit, backup/WAL setup, or restore/break-glass evidence.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
`REQ-REC-001`. A consumer using the advertised JSON Schema can accept weakened high-assurance values, PITR without WAL, or an impossible RPO. The task plan has no lane accountable for closing that acceptance criterion.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Export a normative `validateRecoveryPostureV1`/schema refinement with machine-testable cross-field checks and add a bounded Infra/recovery slice (serialized if it touches shared config) owning config parsing, override audit, mechanism verification, restore test, and break-glass evidence. Recovery config must continue to expose no authority/gate knobs.
|
|
||||||
|
|
||||||
### KCR-015 — MAJOR — Pure Coordinator slice cannot implement two frozen methods without persistence access
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/mechanical-coordinator.v1.ts:259-263` — `explainEligibility` receives only `taskId`, not a structured snapshot.
|
|
||||||
- `contracts/mechanical-coordinator.v1.ts:289-293` — `recoverFromPostgres` explicitly reads PostgreSQL.
|
|
||||||
- `TASKS.md:145-153` — KBN-200 is a pure engine with no SQL, Drizzle, Gateway, or Valkey.
|
|
||||||
- `TASKS.md:157-164` — persistence belongs to coder3/KBN-210.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
Charter C and internal consistency. coder4 cannot implement the frozen port in a pure package without crossing coder3’s persistence boundary. If coder3 implements the port instead, KBN-200’s acceptance and ownership are misassigned.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Split the contract into a pure decision engine that receives complete immutable snapshots and a persistence/orchestration service port implemented by KBN-210. Move `recoverFromPostgres` and ID-based loading to the adapter/service; make pure explanation accept a snapshot.
|
|
||||||
|
|
||||||
### KCR-016 — MINOR — Health denial code/state pairs are not correlated by type
|
|
||||||
|
|
||||||
**Location**
|
|
||||||
|
|
||||||
- `contracts/health-state.v1.ts:35-61` — either denial code can pair with either degraded state.
|
|
||||||
- `SHARED-CONTRACT.md:188-190` — prose defines `KANBAN_WRITE_UNAVAILABLE` specifically for `write-unavailable`.
|
|
||||||
|
|
||||||
**Violation**
|
|
||||||
|
|
||||||
Health contract precision. A client can receive a semantically inconsistent authoritative body even after KCR-001’s broader state fix.
|
|
||||||
|
|
||||||
**Minimal fix**
|
|
||||||
|
|
||||||
Make deliberate denial a two-variant union with exact code/state pairing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Clean checks / invariants that do hold
|
|
||||||
|
|
||||||
The review did **not** find a gap in these areas:
|
|
||||||
|
|
||||||
- The canon consistently selects current `mosaicstack/stack` + Drizzle/PostgreSQL and rejects greenfield/Prisma revival.
|
|
||||||
- Every artifact states PostgreSQL is the sole writable SOT and Valkey/files are non-authoritative.
|
|
||||||
- Generated `TASKS.md`, `mission.json`, and exports are consistently declared read-only and never import sources. KBN-300’s importer is scoped to immutable legacy JSON/Vikunja snapshots, not generated projections.
|
|
||||||
- Recovery config exposes recovery fields only; it contains no direct fail-open, SOT, Coordinator-authority, or gate-waiver knob.
|
|
||||||
- The Coordinator interface contains no `createTask`, acceptance-edit, gate-waive, certify, merge, release, or provider-close method. `submitForReview` is type-limited to `in_review`, not `done` or `certified`.
|
|
||||||
- Certifier is consistently final independent gate with no merge authority.
|
|
||||||
- The seven canonical task status values match across requirements, shared prose, schema, and Coordinator’s ready/in-review surfaces.
|
|
||||||
- One-active-lease partial uniqueness, no-self-edge, outbox aggregate-revision/event-type uniqueness, optimistic task/project/mission/milestone versions, and N-1 test categories are explicitly present.
|
|
||||||
- The file-tree partition is mostly well separated once the ordering/freeze defects in KCR-007 are corrected.
|
|
||||||
|
|
||||||
## Required re-review scope
|
|
||||||
|
|
||||||
After remediation, re-review at minimum:
|
|
||||||
|
|
||||||
1. health/coordinator discriminated unions and mutation-time health proof;
|
|
||||||
2. proposal/approval/assignment/lease relational model;
|
|
||||||
3. monotonic fencing and composite bindings;
|
|
||||||
4. tenant-safe polymorphic relationships;
|
|
||||||
5. outage-proposal persistence and commands;
|
|
||||||
6. concrete current-main migration map;
|
|
||||||
7. corrected dependency graph and exact API/DTO freeze;
|
|
||||||
8. recovery validator/owner slice;
|
|
||||||
9. all schema and DTO vocabulary alignment.
|
|
||||||
|
|
||||||
## Overall verdict
|
|
||||||
|
|
||||||
**NO-GO — 8 BLOCKERs must be resolved before the v1 contract is frozen or parallel implementation begins.**
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# #751 Native Kanban/SOT canonical publication — Ultron final gate
|
|
||||||
|
|
||||||
**Verdict: GO** — zero BLOCKER/HIGH findings.
|
|
||||||
|
|
||||||
## Scope / integrity
|
|
||||||
|
|
||||||
- Reviewed `/home/hermes/agent-work/stack-kanban-canon` staged delta only: exactly 16 documentation/contract artifacts; no unstaged delta; `git diff --cached --check` passes.
|
|
||||||
- This is a publication canon, not a runtime implementation. The explicit implementation hold prevents feature work until canon merge and prerequisite release (`docs/requirements/native-kanban-sot.md:8-9`; `docs/native-kanban-sot/TASKS.md:45-67`).
|
|
||||||
|
|
||||||
## Acceptance mapping and findings
|
|
||||||
|
|
||||||
| Requirement area | Final evidence / result |
|
|
||||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| Sole PostgreSQL SOT, generated projections, outage proposals | Requirements D3/D4 and fixed invariants prohibit alternate writers and import (`docs/requirements/native-kanban-sot.md:22-23,32-44`). Health contract keeps public observation separate from branded transaction-local proof (`contracts/health-state.v1.ts:44-84`) and freezes 503/409/502/504 mappings (`:91-184`). Proposal table uses workspace-aware event FKs (`contracts/kanban-schema.v1.ts:847-908`); exact submission/acceptance transaction semantics are specified (`SHARED-CONTRACT.md:81-89`). PASS. |
|
|
||||||
| Workspace tenancy, planning, assignments, evidence | Workspace-composite task and proposal relations plus active-member rules are explicit (`SHARED-CONTRACT.md:40-48`; `kanban-schema.v1.ts:587-637,875-908`). Lease/checkpoint relations bind workspace/task/assignment/session/fence, with one active lease and bigint fencing (`:1062-1114`). PASS. |
|
|
||||||
| Coordinator, gates, concurrency/recovery | Pure Coordinator has snapshot-only decision methods (`mechanical-coordinator.v1.ts:186-198`); persistence port owns locked ID validation and recovery (`:371-407`). Requirements forbid Coordinator scope/gate/certification/merge authority and Certifier merge authority (`requirements:39-40`; `MISSION-MANIFEST.md` authority table). Recovery validator rejects unknown fields, PITR/WAL/RPO/storage/high-assurance violations (`recovery-posture.v1.ts:193-369`). PASS. |
|
|
||||||
| Migration/N-1/API/task decomposition | N-1 expand/backfill/compatibility/switch/contract order and proposal DDL sequence are concrete (`SHARED-CONTRACT.md:69-115`). Frozen exact Gateway/DTO registry and non-overlapping lane ownership/prerequisites are present (`SHARED-CONTRACT.md:244-282`; `TASKS.md:45-67,81-259`). PASS. |
|
|
||||||
| Documentation / seven owner decisions / evidence | D1–D7 are all explicitly ratified (`requirements:20-26`); all 26 REQ sections contain acceptance criteria. Index/manifest/task graph link requirements, frozen contracts, ownership, and evidence. Relative-link audit passes. PASS. |
|
|
||||||
|
|
||||||
## Independent verification performed
|
|
||||||
|
|
||||||
```text
|
|
||||||
git diff --cached --check PASS
|
|
||||||
./node_modules/.bin/prettier --check <all publication paths> PASS
|
|
||||||
./node_modules/.bin/tsc --noEmit --strict <health/coordinator/recovery> PASS
|
|
||||||
Python relative Markdown link audit PASS (0 errors)
|
|
||||||
Python requirement acceptance audit PASS (26 requirements; 0 missing acceptance sections)
|
|
||||||
Static staged scope/status check PASS (16 staged docs-only; no unstaged delta)
|
|
||||||
```
|
|
||||||
|
|
||||||
The full schema-contract strict type check cannot resolve `drizzle-orm` from this docs-only worktree; this is an environment dependency-resolution limitation, not a contract diagnostic. Independent external publication validation and final re-review record the strict all-four-contract check against the current Stack Drizzle toolchain as PASS.
|
|
||||||
|
|
||||||
## Residual items
|
|
||||||
|
|
||||||
- **LOW:** implementation must deliver the declared KBN-100/KBN-110/KBN-140 proposal-event-chain, tenant, failure-mapping, and SecReview evidence before P0/P1 release. This is a forward implementation obligation already frozen in the canon, not a publication defect.
|
|
||||||
- **LOW:** selected infrastructure backup provider/recovery tier and migration/cutover thresholds remain owner-controlled implementation decisions, bounded by the normative recovery contract and change control.
|
|
||||||
|
|
||||||
No source, staging, commit, provider, CI, or deployment state was mutated.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
# Native Kanban and Canonical Task SOT — Canonical Requirements
|
|
||||||
|
|
||||||
**Status:** RATIFIED and independently approved for canonical publication under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
|
||||||
**Date:** 2026-07-14
|
|
||||||
**Decision owner:** Jason
|
|
||||||
**Publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage)
|
|
||||||
**Implementation foundation:** current `mosaicstack/stack` main only
|
|
||||||
**Implementation hold:** no feature implementation begins until this canon is squash-merged to `main` with terminal-green CI.
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
Deliver Mosaic Stack's native project/task control plane and thin writable Kanban on one authoritative PostgreSQL model. This document formalizes the ratified source plan; it does not create a parallel design.
|
|
||||||
|
|
||||||
Normative terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are binding as used here.
|
|
||||||
|
|
||||||
## 2. Ratified decisions
|
|
||||||
|
|
||||||
| # | Ratified decision | Canonical result |
|
|
||||||
| --- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| D1 | Foundation | Extend current `mosaicstack/stack` main with its existing Drizzle/PostgreSQL, NestJS Gateway, Next.js, Better Auth, and Valkey/BullMQ conventions. No greenfield service and no Prisma revival. |
|
|
||||||
| D2 | Tenant boundary | `workspace_id` is the hard tenant boundary from the first migration. Teams are authorization groups inside a workspace, never tenant substitutes. |
|
|
||||||
| D3 | Outage authority — Option A with amendment | PostgreSQL is the sole writable SOT and mutations fail closed whenever DB write-health cannot be proven. The amendment permits deployment-specific **recovery posture only**; it does not permit an alternate writer. Human outage notes are attributable post-recovery proposals, never shadow state. |
|
|
||||||
| D4 | Generated files | `TASKS.md`, `mission.json`, and any file export are generated, read-only, non-authoritative, and never import sources. Generate on demand; commit only where repository review policy requires a snapshot. |
|
|
||||||
| D5 | Status model | Task statuses are `backlog`, `ready`, `in_progress`, `blocked`, `in_review`, `done`, `cancelled`. Runtime readiness is orthogonal and computed. |
|
|
||||||
| D6 | Coordinator approval | Hybrid: manual Project Sub-Orchestrator approval by default; automatic routing only under an explicit, approved, versioned low-risk policy. |
|
|
||||||
| D7 | Initial migration scope | Project, mission, milestone, task, tags/archive, dependency, assignment, outage proposal, evidence/link, and orchestration state only. Calendar, email, GLPI cache, and personal-brain features remain out of scope. |
|
|
||||||
|
|
||||||
## 3. Fixed invariants — every deployment
|
|
||||||
|
|
||||||
These are not tier settings and cannot be weakened by deployment configuration.
|
|
||||||
|
|
||||||
1. PostgreSQL is the **sole writable source of truth**.
|
|
||||||
2. The implementation uses Drizzle on current stack main.
|
|
||||||
3. Kanban and orchestration mutations **fail closed** unless DB write-health is positively proven `healthy`.
|
|
||||||
4. No failed mutation is redirected to Markdown, JSON, browser storage, Valkey, queue payloads, scratchpads, or provider issues.
|
|
||||||
5. `TASKS.md` and all file exports are generated, read-only, non-authoritative, and never parsed for import.
|
|
||||||
6. Human notes created during an outage become attributable proposals only after recovery. They do not reserve work, change status, satisfy a gate, or establish ordering.
|
|
||||||
7. Valkey is derived, expendable coordination infrastructure. PostgreSQL retains task truth, leases, fencing, audit, and the transactional outbox.
|
|
||||||
8. The Mechanical Coordinator is non-LLM and deterministic. It may evaluate eligibility, dependencies, approval policy, leases, fencing, heartbeat, retry, expiry, and quarantine. It cannot invent scope, alter acceptance criteria, waive gates, certify, or merge.
|
|
||||||
9. **Certifier** is the final independent quality-gate role. Certifier may pass, reject, or escalate with evidence; it has no merge authority.
|
|
||||||
10. Every business and orchestration record is workspace-scoped; cross-workspace relationships are rejected.
|
|
||||||
11. Every mutation is idempotent and expected-version checked where it changes an aggregate.
|
|
||||||
12. Stale worker mutations are rejected by monotonically increasing fencing tokens.
|
|
||||||
13. Audit events are append-only and attributable; authoritative state is reconstructable from PostgreSQL without Valkey or files.
|
|
||||||
|
|
||||||
## 4. Configurable recovery posture only
|
|
||||||
|
|
||||||
Deployment tiers configure durability and operational recovery targets. They never configure SOT authority, fail-open writes, or gate bypass.
|
|
||||||
|
|
||||||
### 4.1 Tier defaults
|
|
||||||
|
|
||||||
| Setting | Lite | Standard | High-assurance |
|
|
||||||
| --------------------------- | --------------------------------------: | ------------------------------------------------------------: | ----------------------------------------------------------------------: |
|
|
||||||
| Target RPO | 24 hours | 1 hour | **15 minutes** |
|
|
||||||
| Target RTO | 24 hours | 8 hours | **4 hours** |
|
|
||||||
| Base backup cadence | Daily | Daily | **Daily** |
|
|
||||||
| WAL archive cadence | Disabled | Every 15 minutes | **Every 5 minutes** |
|
|
||||||
| PITR retention | 0 days / disabled | 14 days | **35 days** |
|
|
||||||
| Restore test frequency | Quarterly | Quarterly | **Monthly** |
|
|
||||||
| Break-glass drill frequency | Annually | Semiannually | **Quarterly** |
|
|
||||||
| Off-cluster storage | One encrypted off-cluster backup target | Encrypted off-cluster object storage, separate failure domain | **Encrypted off-cluster base backups and WAL, separate failure domain** |
|
|
||||||
|
|
||||||
A deployment MAY override defaults only through the validated recovery-posture contract. An override MUST record actor, reason, effective time, and policy revision. A claimed RPO MUST be no smaller than the actual backup/WAL mechanism can support. Enabling PITR requires WAL archival and off-cluster storage.
|
|
||||||
|
|
||||||
## 5. Functional requirements and acceptance criteria
|
|
||||||
|
|
||||||
### REQ-SOT-001 — Sole writable PostgreSQL authority
|
|
||||||
|
|
||||||
**Requirement:** All project, mission, milestone, task/tag/archive, dependency, assignment, execution/quarantine, lease, checkpoint, approval, outage proposal, event, link, artifact, and outbox mutations MUST commit through Gateway domain services into PostgreSQL.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Mutation journey tests show web, CLI, MCP, and agents invoke typed Gateway commands.
|
|
||||||
- Static/process inventory finds no file, Valkey, browser, or provider issue writer acting as canonical state.
|
|
||||||
- PostgreSQL state survives Valkey loss and reconstructs the same aggregate revisions.
|
|
||||||
|
|
||||||
### REQ-SOT-002 — Fail-closed mutation health
|
|
||||||
|
|
||||||
**Requirement:** A mutation MUST execute only while health state is `healthy`. `read-only-degraded` and `write-unavailable` MUST return the frozen deliberate-denial error contract and MUST NOT enqueue a hidden write.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Public health response is a discriminated union; contradictory state/proof combinations fail contract validation.
|
|
||||||
- Mutation methods accept only a fresh internal PostgreSQL transaction-local write proof, never caller-asserted/public health state.
|
|
||||||
- Negative tests reject expired proofs, policy-revision mismatch, Valkey-only liveness, and caller-forged `healthy`.
|
|
||||||
- Fault tests force both degraded states and prove row counts, outbox, files, and Valkey remain unchanged.
|
|
||||||
- Exact failure mapping proves authoritative 503 denial, retryable 502/504/timeout uncertainty, and 409 version conflict cannot cross-map.
|
|
||||||
- Replaying the same idempotency key after recovery returns one canonical result.
|
|
||||||
|
|
||||||
### REQ-SOT-003 — Generated projections
|
|
||||||
|
|
||||||
**Requirement:** `TASKS.md`, `mission.json`, and other exports MUST contain a non-authoritative header, workspace/project IDs, generated time, and source revision. No production parser may mutate DB from an export.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Generated output matches the API snapshot revision.
|
|
||||||
- Hand editing a projection fails CI validation or is overwritten by regeneration.
|
|
||||||
- Repository search finds no import path from generated projections.
|
|
||||||
|
|
||||||
### REQ-SOT-004 — Attributable outage proposals
|
|
||||||
|
|
||||||
**Requirement:** Human outage notes MAY be captured outside the system but, after recovery, can enter Mosaic only through workspace-scoped `change_proposals` attributed to an authenticated active member. A proposal stores source-note digest, target aggregate/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, and audit links. It MUST NOT silently change canonical state.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` are composite foreign keys to `task_events(workspace_id, id)`; missing and foreign-workspace event IDs fail before commit.
|
|
||||||
- Submission preallocates the proposal ID and atomically inserts `change_proposal.submitted` for that exact workspace/proposal with the new proposal referencing it.
|
|
||||||
- Accept locks proposal and target, obtains fresh write proof, checks expected version, executes the normal typed command, and atomically links that command's event for the same workspace/target and proposal causation.
|
|
||||||
- Negative tests reject missing submission events, foreign-workspace submission/acceptance events, and same-workspace events for an unrelated proposal, aggregate, target, or command.
|
|
||||||
- Tests prove a pending/rejected proposal cannot claim/order work, satisfy a dependency/gate, or mutate any target directly.
|
|
||||||
|
|
||||||
### REQ-TEN-001 — Workspace hard tenancy
|
|
||||||
|
|
||||||
**Requirement:** Every canonical business/orchestration row MUST carry `workspace_id`. Workspace-aware constraints and authorization MUST prevent cross-tenant relationships and reads/writes.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- API, repository, import, WebSocket, and Coordinator negative tests reject foreign-workspace IDs without existence oracles.
|
|
||||||
- Project/task owners use exactly-one user/team references; assignment principals use exactly-one user/team/agent reference; agent/session targets are workspace-consistent.
|
|
||||||
- User owners, principals, proposers, and decision actors require ACTIVE workspace membership in the authoritative transaction.
|
|
||||||
- Dependency, project hierarchy, assignment, lease, checkpoint, approval-evidence, link, artifact, proposal target, and both proposal-audit-event composite relationships reject mixed workspaces.
|
|
||||||
- Tenant context is derived from authenticated authority, never accepted blindly from request data.
|
|
||||||
|
|
||||||
### REQ-ID-001 — Workspace identity and service scope
|
|
||||||
|
|
||||||
**Requirement:** Users, teams, agents, and agent sessions MUST be bound to a workspace with explicit role/capability scope. Agents MUST NOT receive raw DB credentials.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Workspace membership and service-identity tests enforce command-family scope.
|
|
||||||
- Revoked/disabled agents and ended sessions cannot claim, heartbeat, or submit.
|
|
||||||
|
|
||||||
### REQ-PLAN-001 — Normalized planning hierarchy
|
|
||||||
|
|
||||||
**Requirement:** Canonical planning entities are projects, milestones, missions, mission-milestone associations, and tasks. A task belongs to one required project and at most one mission/milestone/parent task.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- CRUD tests preserve workspace, hierarchy, versions, and lifecycle constraints.
|
|
||||||
- Mission membership does not duplicate task status.
|
|
||||||
- Composite project-congruent constraints reject task→mission, task→milestone, task→parent, mission→milestone, and project→current-milestone mismatches.
|
|
||||||
- Parent and association constraints reject cycles/orphans where applicable.
|
|
||||||
|
|
||||||
### REQ-TASK-001 — Canonical task fields
|
|
||||||
|
|
||||||
**Requirement:** Tasks MUST support title, description, structured acceptance criteria, canonical status, priority, fractional board rank, accountable owner, assigned specialist role, due/not-before dates, estimate, progress, explicit blocker, retry policy, normalized workspace tags, non-lifecycle archival (`archived_at/by/reason`), metadata, monotonic fencing counter, and optimistic version.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- API and UI round-trip every field without silent loss.
|
|
||||||
- Current `tasks.tags`, `assignee`, and `due_date` remain declared/preserved during N-1 and backfill to the canonical model without loss.
|
|
||||||
- Archive hides work without changing its canonical lifecycle status and requires actor/reason/time.
|
|
||||||
- Invalid status, rank, progress, date, owner, tag, archive, or retry data is rejected.
|
|
||||||
- Concurrent expected-version updates produce a visible conflict.
|
|
||||||
|
|
||||||
### REQ-TASK-002 — Fixed lifecycle and computed readiness
|
|
||||||
|
|
||||||
**Requirement:** Human workflow status MUST use the seven ratified values. Dependency/schedule/policy/lease/retry conditions MUST be exposed as computed readiness, not hidden status rewrites.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- A dependency becoming incomplete changes readiness but does not silently rewrite the Kanban column.
|
|
||||||
- Readiness explanation identifies all active gates.
|
|
||||||
- State-machine tests reject illegal transitions and require reasons for blocked/cancelled paths.
|
|
||||||
|
|
||||||
### REQ-DEP-001 — Dependency DAG
|
|
||||||
|
|
||||||
**Requirement:** Workspace-local directed dependencies MUST be unique and acyclic. A task is dependency-eligible only after every blocking predecessor is `done` and completion conditions pass.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- `(workspace_id, predecessor_task_id, successor_task_id)` is unique independent of dependency type.
|
|
||||||
- Cycle, duplicate, self-edge, and cross-workspace attempts fail before commit.
|
|
||||||
- Property/concurrency tests prove all blocking predecessors are evaluated.
|
|
||||||
- UI displays dependency and readiness errors accessibly.
|
|
||||||
|
|
||||||
### REQ-ASN-001 — Assignment is not a lease
|
|
||||||
|
|
||||||
**Requirement:** Assignment history and execution leases MUST be separate records. One persisted assignment identity freezes task version, exact target agent/session (or exactly-one non-agent principal), specialist role, expiry, state, policy revision, proposer, reason, and timestamps. Approval decisions relate to that assignment with workspace-aware constraints.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- One assignment-state vocabulary is identical across schema, DTO, and engine.
|
|
||||||
- Lease acquisition accepts IDs only, then reloads and locks assignment, approval, task, and target session to verify workspace, current task version, exact target, state, expiry, and policy revision.
|
|
||||||
- Reassignment preserves history; assignment may exist without a lease; lease expiry does not erase ownership/evidence.
|
|
||||||
|
|
||||||
### REQ-AUD-001 — Semantic audit and outbox
|
|
||||||
|
|
||||||
**Requirement:** Mutating commands MUST append semantic `task_events` with actor, correlation, causation, idempotency key, and aggregate versions in the same transaction as state. Notifications MUST flow from a transactional outbox.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Atomicity tests prove state/event/outbox commit or roll back together.
|
|
||||||
- Proposal submission and acceptance tests prove their workspace-bound event links identify the exact submission and executed normal command, not merely an existing event UUID.
|
|
||||||
- Duplicate idempotency keys return the prior result without duplicate events.
|
|
||||||
- `task_events`, checkpoints, immutable artifacts, and evidence joins are INSERT/SELECT-only for application roles; parent hard deletes are RESTRICTed.
|
|
||||||
- Normal lifecycle uses archive/cancel, never hard delete; retention purge requires audited break-glass authority and evidence.
|
|
||||||
- Valkey outage leaves outbox pending and later replayable.
|
|
||||||
|
|
||||||
### REQ-API-001 — Typed Gateway command boundary
|
|
||||||
|
|
||||||
**Requirement:** Gateway MUST expose workspace-safe project/task/dependency/assignment/link/artifact/change-proposal queries and explicit lifecycle commands. Generic patching MUST NOT bypass claim, heartbeat, review, certify, proposal acceptance, or completion invariants.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- KBN-105 freezes exact route, request, success, denial, conflict, and transport-normalization DTOs before CLI/web implementation.
|
|
||||||
- DTO validation, authorization, contract, and integration tests cover each command.
|
|
||||||
- Exact MCP-owned Gateway files are coder3-owned; coder4 consumes only frozen Gateway contracts.
|
|
||||||
- Endpoint registry aligns web, CLI, MCP, and generated client paths.
|
|
||||||
- Direct SQL and raw Valkey writes are absent from clients.
|
|
||||||
|
|
||||||
### REQ-UI-001 — Writable thin Kanban/List MVP
|
|
||||||
|
|
||||||
**Requirement:** Existing Tasks and Projects surfaces MUST become a real-data writable MVP with one shared query contract.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Users can create/edit/cancel/archive tasks, open task detail, and move cards within/across columns.
|
|
||||||
- Server validates transition and persists fractional board rank.
|
|
||||||
- Refresh, reconnect, CLI, MCP, and generated projection show the same revision.
|
|
||||||
|
|
||||||
### REQ-UI-002 — Tenant and work context
|
|
||||||
|
|
||||||
**Requirement:** UI MUST show workspace context and support filters for project, mission, milestone, status, priority, owner/specialist, due state, and tags.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Context is visible on every mutation surface.
|
|
||||||
- Filter tests cannot expose foreign-workspace data.
|
|
||||||
- Empty/loading/error states are explicit.
|
|
||||||
|
|
||||||
### REQ-UI-003 — Dependency, ownership, lease, and audit visibility
|
|
||||||
|
|
||||||
**Requirement:** Task detail MUST separate accountable owner, specialist assignment, active session/lease expiry, dependencies/readiness, acceptance criteria, blocker, external links, and audit timeline.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Each concept renders from its canonical endpoint.
|
|
||||||
- A lease is never displayed as ownership or completion.
|
|
||||||
- Conflict and stale-reconnect states require refresh rather than silent overwrite.
|
|
||||||
|
|
||||||
### REQ-UI-004 — Accessible interaction
|
|
||||||
|
|
||||||
**Requirement:** Kanban MUST support keyboard-accessible moves, non-drag alternatives, responsive layout, and semantic status/error announcements.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Keyboard journey performs every card transition available by drag.
|
|
||||||
- Automated accessibility checks and manual responsive checks pass.
|
|
||||||
|
|
||||||
### REQ-COORD-001 — Non-LLM Mechanical Coordinator
|
|
||||||
|
|
||||||
**Requirement:** Coordinator decisions MUST be deterministic from structured data and versioned policy. It MUST NOT invoke an LLM to interpret scope or acceptance criteria.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Pure decision engine receives complete immutable snapshots and performs no ID loading, SQL, Gateway, Valkey, or recovery I/O.
|
|
||||||
- Persistence/service adapter owns ID loading, transaction-local write proof, locking, persistence, and `recoverFromPostgres`.
|
|
||||||
- Same snapshot and policy revision produce the same eligibility/order explanation.
|
|
||||||
- Dependency, schedule, durable retry/quarantine, approval, role, and capacity inputs are auditable.
|
|
||||||
- Code/config inspection finds no model/provider dependency in the scheduling engine.
|
|
||||||
|
|
||||||
### REQ-COORD-002 — Eligibility and approval routing
|
|
||||||
|
|
||||||
**Requirement:** Only `ready` tasks under active project/mission, passed dependencies/schedule/retry/release policy, and without active lease may be proposed. Manual approval is default; auto-route requires an explicit approved policy revision.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Unapproved or gated tasks are never leased.
|
|
||||||
- Every persisted assignment proposal includes task version, exact target agent/session, expiry, state, deterministic reasons, and policy revision.
|
|
||||||
- Approval is relationally bound to the assignment identity and cannot be supplied as a forgeable proof-by-value DTO.
|
|
||||||
- Override/reject/reassign requires an attributable reason.
|
|
||||||
|
|
||||||
### REQ-COORD-003 — Atomic lease, heartbeat, fencing, and recovery
|
|
||||||
|
|
||||||
**Requirement:** Lease acquisition MUST be atomic in PostgreSQL, permit at most one active lease per task, atomically increment the durable per-task fencing counter under task lock, use bigint-safe tokens, require timely acknowledgement/heartbeat, and reject stale workers. Lease and checkpoint relations MUST bind the exact workspace+task+assignment/session+fence.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Concurrent claim tests yield one winner and strictly increasing fencing tokens.
|
|
||||||
- Lower/expired tokens and mismatched same-workspace task/assignment/lease/checkpoint IDs fail.
|
|
||||||
- Token values round-trip as bigint/decimal strings without JavaScript precision loss.
|
|
||||||
- Coordinator restart reconstructs lease/retry/quarantine state from PostgreSQL alone.
|
|
||||||
|
|
||||||
### REQ-COORD-004 — Retry and quarantine
|
|
||||||
|
|
||||||
**Requirement:** Missing acknowledgement, agent loss, or execution failure MUST produce a deterministic release, bounded backoff retry, or quarantine outcome according to retry policy. Ambiguous/non-idempotent work requires Sub-Orchestrator action.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Durable execution state records disposition, attempt/max, next eligibility, terminal reason, actor/policy, timestamps, and version.
|
|
||||||
- Retry budget/backoff are bounded and tested.
|
|
||||||
- Exhausted or non-idempotent failures quarantine with workspace-scoped artifact evidence.
|
|
||||||
- One specialist-role vocabulary is enforced across schema, sessions, assignments, DTOs, and engine.
|
|
||||||
- No task loops indefinitely or silently returns to ready.
|
|
||||||
|
|
||||||
### REQ-GATE-001 — Role and authority chain
|
|
||||||
|
|
||||||
**Requirement:** Canonical flow is User → Interaction → Portfolio Orchestrator → Project Sub-Orchestrator → Gateway → domain services → Mechanical Coordinator → specialists → Certifier.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Role bindings and approvals are queryable and audited.
|
|
||||||
- Coordinator cannot create scope or waive gates.
|
|
||||||
- Certifier cannot merge or close provider artifacts.
|
|
||||||
|
|
||||||
### REQ-GATE-002 — Independent review and certification
|
|
||||||
|
|
||||||
**Requirement:** Author and reviewer MUST differ. Auth, security, tenant, secrets, and data-integrity surfaces MUST receive mandatory SecReview. Certifier is the final quality gate after remediation.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Gate tests reject author self-review and missing required SecReview.
|
|
||||||
- Certifier receives complete traceability/evidence and returns pass/reject/escalate.
|
|
||||||
- A Certifier pass does not grant merge authority.
|
|
||||||
|
|
||||||
### REQ-REC-001 — Recovery posture validation
|
|
||||||
|
|
||||||
**Requirement:** A deployment MUST select a validated Lite, Standard, or High-assurance posture and MAY override only recovery knobs.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Runtime invokes normative `validateRecoveryPostureV1`, not shape-only JSON Schema validation.
|
|
||||||
- Validator rejects PITR/WAL mismatch, impossible RPO, unknown fields, non-encrypted/non-separated storage, and weakened High-assurance values.
|
|
||||||
- A bounded recovery/infra slice owns parser wiring, override audit, mechanism verification, restore test, and break-glass evidence.
|
|
||||||
- High-assurance defaults equal RPO 15m/RTO 4h, encrypted off-cluster WAL every 5m, 35d PITR, daily base backup, monthly restore test, and quarterly break-glass.
|
|
||||||
|
|
||||||
### REQ-MIG-001 — One-way shadow migration
|
|
||||||
|
|
||||||
**Requirement:** Migration from jarvis-brain/Vikunja MUST use inventory, immutable source snapshots/checksums, one-way shadow import, read reconciliation, write freeze, final delta, cutover, and read-only stabilization. Dual writes are forbidden.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- P0 publishes the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract map before any schema lane starts.
|
|
||||||
- Legacy columns remain in the unified Drizzle declaration for the entire expand/N-1 window.
|
|
||||||
- Dry-run/apply/verify modes are idempotent and workspace-safe.
|
|
||||||
- Import lineage preserves source system/key/file/checksum/batch and rejected-record reports.
|
|
||||||
- Empty DB, production-shape, partial-resume, downgrade/rollback, status-shadow, workspace-backfill, and `mission_tasks.status` retirement tests pass.
|
|
||||||
- Shadow records cannot auto-dispatch.
|
|
||||||
|
|
||||||
### REQ-MIG-002 — Cutover and rollback safety
|
|
||||||
|
|
||||||
**Requirement:** Cutover MUST disable legacy writers and switch all clients to Gateway. Before first DB mutation rollback may switch authority back; afterward rollback requires freeze, DB-delta export/reconciliation, and owner decision.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
|
|
||||||
- Process inventory proves no active jarvis-brain/Vikunja project/task writer.
|
|
||||||
- Cutover rehearsal meets signed reconciliation thresholds.
|
|
||||||
- No reverse and forward sync run concurrently.
|
|
||||||
|
|
||||||
## 6. Explicit non-goals
|
|
||||||
|
|
||||||
The P0–P3 canon does not authorize:
|
|
||||||
|
|
||||||
- replacing Gitea issue/PR storage;
|
|
||||||
- calendar, email, GLPI cache, CRM, billing, time tracking, or personal-brain migration;
|
|
||||||
- arbitrary custom workflows/statuses/fields;
|
|
||||||
- a writable offline/file/Valkey/browser fallback;
|
|
||||||
- direct client database access;
|
|
||||||
- LLM scheduling or autonomous scope invention;
|
|
||||||
- Coordinator gate waiver, certification, merge, release, or provider issue closure;
|
|
||||||
- Certifier merge authority;
|
|
||||||
- full mission designer, portfolio analytics, critical-path UX, or advanced board customization in the thin MVP;
|
|
||||||
- P4/P5 features unless separately released.
|
|
||||||
|
|
||||||
## 7. Global release evidence
|
|
||||||
|
|
||||||
P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
# Issue #751 — Native Kanban/SOT canonical publication
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Publish the owner-ratified P0–P3 requirements, mission manifest, task decomposition, and frozen shared contracts before feature implementation.
|
|
||||||
|
|
||||||
## Authority and decisions
|
|
||||||
|
|
||||||
- Owner: Jason
|
|
||||||
- Plan owner/orchestrator: web1 control plane; takeover by mosaic-100 during Claude quota outage
|
|
||||||
- Tracking: Mosaic Stack issue #751
|
|
||||||
- Foundation: current Stack main + Drizzle/PostgreSQL
|
|
||||||
- Fixed invariants: PostgreSQL sole writable SOT; writes fail closed; exports never import; outage notes become attributable proposals; mechanical Coordinator has no scope/gate/certify/merge authority; Certifier has no merge authority.
|
|
||||||
- Recovery posture only is configurable through Lite, Standard, and High-assurance profiles.
|
|
||||||
|
|
||||||
## Execution log
|
|
||||||
|
|
||||||
- 2026-07-14: Existing planner-sol canon remediation reviewed from staging. KCR-001–016 claimed resolved; static checks passed.
|
|
||||||
- 2026-07-14: Independent GPT/Terra re-review dispatched to rev1.
|
|
||||||
- 2026-07-14: Re-review returned NO-GO: proposal audit-event IDs were not workspace-bound, leaving attribution forgeable; formatter evidence was not reproducible. Focused remediation round 2 routed to planner-sol.
|
|
||||||
- 2026-07-14: Remediation bound proposal audit links to `task_events(workspace_id,id)`, froze same-transaction semantic validation and negative tests, and made formatter/type/static checks reproducible.
|
|
||||||
- 2026-07-14: Independent rev1 re-review returned GO with KCR-001–016 closed and no new blocker. Canon copied into the issue #751 publication worktree; feature implementation remains held until merge.
|
|
||||||
- 2026-07-14: Independent publication validation returned FAIL on formatting/trailing whitespace, stale staging wording, ignored review evidence, and missing worktree dependencies. Bounded publication remediation routed to planner-sol; no runtime source change authorized.
|
|
||||||
- 2026-07-14: Publication remediation installed locked dependencies outside the repository cache, fixed formatting and wording, and preserved docs-only scope. Independent gaterun revalidation returned PASS across staged scope, formatting, lint, typecheck, strict contract compile, links, rollups, review artifacts, and fixed invariants.
|
|
||||||
- 2026-07-14: Ultron final gate returned GO with zero BLOCKER/HIGH findings; residual LOW items remain explicit implementation obligations.
|
|
||||||
- 2026-07-14: First commit attempt was correctly blocked by the lint-staged hook because docs contract `.ts` files were outside TypeScript project-service scope. Added a strict no-emit workstream `tsconfig.json` with exact Drizzle declaration paths; targeted contract TSC, contract ESLint, format, full lint/typecheck, strictness, and docs-only scope independently passed.
|
|
||||||
|
|
||||||
## Verification evidence
|
|
||||||
|
|
||||||
- Initial review: `docs/reports/native-kanban-sot/canon-initial-review-no-go.md`
|
|
||||||
- Final GO: `docs/reports/native-kanban-sot/canon-final-rereview-go.md`
|
|
||||||
- Ultron GO: `docs/reports/native-kanban-sot/ultron-final-go.md`
|
|
||||||
- Pending: PR gates, squash merge, terminal-green CI, and issue closure.
|
|
||||||
|
|
||||||
## Publication remediation session — planner-sol
|
|
||||||
|
|
||||||
**Assignment:** Remediate only issue #751 publication blockers from `/home/hermes/agent-work/reviews/751-kanban-canon-publication-validation.md`; no source, package, lock, provider, CI, build, deploy, commit, or push action.
|
|
||||||
|
|
||||||
**Plan:**
|
|
||||||
|
|
||||||
1. Install the locked pnpm dependencies without modifying package metadata.
|
|
||||||
2. Replace only stale publication/staging wording while retaining the canon-merge and KBN prerequisite implementation hold.
|
|
||||||
3. Format all changed candidate Markdown and contract TypeScript; remove trailing whitespace.
|
|
||||||
4. Run worktree `format:check`, `lint`, `typecheck`, strict no-emit contract compilation, tracked/untracked whitespace checks, and static invariants.
|
|
||||||
5. Verify package/lock/source paths are unchanged and append exact evidence here.
|
|
||||||
|
|
||||||
**Budget:** No explicit token cap; bounded docs-only remediation, no exploratory/runtime work.
|
|
||||||
**TDD:** Not applicable—documentation/contract-publication formatting and wording only; strict/static validation is the primary gate.
|
|
||||||
|
|
||||||
## Publication remediation results
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- `docs/native-kanban-sot/INDEX.md`: replaced staging/pending-GO wording with current publication and independent-GO wording; retained the merge hold and dependency-ordered KBN prerequisite hold.
|
|
||||||
- `docs/native-kanban-sot/TASKS.md`: replaced “Mos using this staging set” with “Mos / publication control plane”; made the post-merge KBN prerequisite hold explicit.
|
|
||||||
- Formatted all changed candidate Markdown and four contract TypeScript files with current-worktree Prettier 3.8.1.
|
|
||||||
- Removed trailing whitespace from candidate Markdown, including both linked review reports.
|
|
||||||
- Preserved both review reports and their links; they remain ignored by `.gitignore:11` for coordinator force-tracking.
|
|
||||||
|
|
||||||
### Dependency installation
|
|
||||||
|
|
||||||
The first target-worktree install attempt used the environment's default root-owned pnpm store and failed without changing package metadata:
|
|
||||||
|
|
||||||
```text
|
|
||||||
cd /home/hermes/agent-work/stack-kanban-canon && pnpm install --frozen-lockfile
|
|
||||||
EACCES: permission denied, open '/root/.local/share/pnpm/store/v10/server/server.json'
|
|
||||||
```
|
|
||||||
|
|
||||||
Successful locked install using an authorized cache outside the repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/hermes/agent-work/stack-kanban-canon
|
|
||||||
pnpm install --frozen-lockfile --store-dir /home/hermes/agent-work/pnpm-store
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: PASS, 1,240 packages installed; lockfile resolution skipped as up to date. `node_modules` remains ignored. Tool versions: pnpm 10.6.2, Prettier 3.8.1, TypeScript 5.9.3, Drizzle ORM 0.45.1, Turbo 2.8.16.
|
|
||||||
|
|
||||||
### Exact quality-gate results
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm format:check
|
|
||||||
PASS — All matched files use Prettier code style.
|
|
||||||
|
|
||||||
pnpm lint
|
|
||||||
PASS — 23 successful lint tasks.
|
|
||||||
|
|
||||||
pnpm typecheck
|
|
||||||
PASS — 42 successful tasks. Turbo invoked configured dependency build prerequisites as part of the repository's exact typecheck graph; no standalone build command was run.
|
|
||||||
```
|
|
||||||
|
|
||||||
Candidate formatting commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm exec prettier --write <3 tracked rollups + 9 native-kanban artifacts + requirements + scratchpad>
|
|
||||||
pnpm exec prettier --check <same files>
|
|
||||||
pnpm exec prettier --ignore-path /dev/null --write \
|
|
||||||
docs/reports/native-kanban-sot/canon-initial-review-no-go.md \
|
|
||||||
docs/reports/native-kanban-sot/canon-final-rereview-go.md
|
|
||||||
pnpm exec prettier --ignore-path /dev/null --check \
|
|
||||||
docs/reports/native-kanban-sot/canon-initial-review-no-go.md \
|
|
||||||
docs/reports/native-kanban-sot/canon-final-rereview-go.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: PASS. The explicit `/dev/null` ignore path is required because `docs/reports/` is intentionally ignored pending coordinator force-tracking.
|
|
||||||
|
|
||||||
Tracked and untracked whitespace checks:
|
|
||||||
|
|
||||||
```text
|
|
||||||
git diff --check
|
|
||||||
PASS
|
|
||||||
|
|
||||||
git diff --no-index --check /dev/null <each untracked/ignored candidate>
|
|
||||||
PASS for all candidates
|
|
||||||
```
|
|
||||||
|
|
||||||
Strict contract compilation initially could not resolve pnpm-isolated `drizzle-orm` from the external docs directory. A temporary, removed dependency-context symlink made current-worktree resolution explicit:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
LINK=docs/native-kanban-sot/node_modules
|
|
||||||
ln -s ../../packages/db/node_modules "$LINK"
|
|
||||||
trap 'unlink "$LINK"' EXIT
|
|
||||||
pnpm exec tsc \
|
|
||||||
--noEmit \
|
|
||||||
--strict \
|
|
||||||
--skipLibCheck \
|
|
||||||
--target ES2022 \
|
|
||||||
--module NodeNext \
|
|
||||||
--moduleResolution NodeNext \
|
|
||||||
docs/native-kanban-sot/contracts/*.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: `strict-contract-noemit=PASS`; temporary link removed.
|
|
||||||
|
|
||||||
Static result:
|
|
||||||
|
|
||||||
```text
|
|
||||||
proposal-audit-links=PASS
|
|
||||||
kcr-invariant-regression=PASS
|
|
||||||
publication-wording=PASS
|
|
||||||
vocabulary-alignment=PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scope-integrity evidence
|
|
||||||
|
|
||||||
Baseline and final hashes are identical:
|
|
||||||
|
|
||||||
```text
|
|
||||||
package.json 93a50eaefc7a0446a56234e427df03f6a2256f8da17c0bede17c22206928c8c0
|
|
||||||
pnpm-lock.yaml 8b6448d51ac7797c8f782af52a080c0e38ab8bf364f32624f94e636bf5743229
|
|
||||||
```
|
|
||||||
|
|
||||||
`tracked-package-lock-source-unchanged=PASS`: every tracked/untracked nonignored change remains under `docs/`; no package, lock, application source, plugin source, configuration, CI trigger, standalone build/deploy, container, provider, commit, or push action occurred.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -18,12 +18,12 @@ Implement a transport-neutral coordination contract allowing a configured intera
|
|||||||
|
|
||||||
## Design checkpoint — 2026-07-12
|
## Design checkpoint — 2026-07-12
|
||||||
|
|
||||||
Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryInteractionCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task.
|
Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryMosCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task.
|
||||||
|
|
||||||
## Progress checkpoint — 2026-07-13
|
## Progress checkpoint — 2026-07-13
|
||||||
|
|
||||||
- Implemented `InteractionCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
|
- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
|
||||||
- Implemented the gateway `InteractionCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context.
|
- Implemented the gateway `MosCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context.
|
||||||
- Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation.
|
- Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation.
|
||||||
- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`.
|
- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`.
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa
|
|||||||
|
|
||||||
### Mos Coordination Boundary
|
### Mos Coordination Boundary
|
||||||
|
|
||||||
`@mosaicstack/coord` exposes only the transport-neutral `InteractionCoordinationPort`
|
`@mosaicstack/coord` exposes only the transport-neutral `MosCoordinationPort`
|
||||||
verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant,
|
verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant,
|
||||||
correlation, and interaction-agent identity from authenticated context plus
|
correlation, and interaction-agent identity from authenticated context plus
|
||||||
trusted configuration; callers never provide an orchestration target. It
|
trusted configuration; callers never provide an orchestration target. It
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This procedure is evidence-bound. It does not authorize a production cutover unt
|
|||||||
2. Query the normalized runtime capability surface, not a Hermes API directly. Confirm the session capabilities required for the operation are advertised.
|
2. Query the normalized runtime capability surface, not a Hermes API directly. Confirm the session capabilities required for the operation are advertised.
|
||||||
3. Query the transitional matrix through `RuntimeProviderService.transitionalCapabilityMatrix` (`apps/gateway/src/agent/runtime-provider-registry.service.ts`). Kanban, skills, memory, tools, and cron must remain `unsupported`; stop rather than route those operations through Hermes.
|
3. Query the transitional matrix through `RuntimeProviderService.transitionalCapabilityMatrix` (`apps/gateway/src/agent/runtime-provider-registry.service.ts`). Kanban, skills, memory, tools, and cron must remain `unsupported`; stop rather than route those operations through Hermes.
|
||||||
4. Route new memory activity through the Mosaic operator-memory plugin path; there is no landed Hermes memory import.
|
4. Route new memory activity through the Mosaic operator-memory plugin path; there is no landed Hermes memory import.
|
||||||
5. Use `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) for orchestration handoff. The interaction agent does not take configured orchestrator authority.
|
5. Use `MosCoordinationService` for orchestration handoff. Tess does not take Mos authority.
|
||||||
6. Record the qualification evidence and only then update an external deployment/channel binding through its separately authorized operational process.
|
6. Record the qualification evidence and only then update an external deployment/channel binding through its separately authorized operational process.
|
||||||
|
|
||||||
No claim here authorizes bulk transcript copying, data-schema migration, or enabling an unsupported transitional capability.
|
No claim here authorizes bulk transcript copying, data-schema migration, or enabling an unsupported transitional capability.
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ Hermes is a reference adapter, not a Mosaic core dependency. `packages/agent/src
|
|||||||
| sessions, hierarchy, streaming, send/attach/terminate | `HermesRuntimeProvider` plus `hermes-runtime-provider.test.ts` | adapted |
|
| sessions, hierarchy, streaming, send/attach/terminate | `HermesRuntimeProvider` plus `hermes-runtime-provider.test.ts` | adapted |
|
||||||
| Kanban, skills, memory, tools, cron | normalized matrix in `HermesRuntimeProvider.transitionalCapabilityMatrix`; each is `unsupported` and `assertTransitionalCapability` denies before a transport call | deferred / fail-closed |
|
| Kanban, skills, memory, tools, cron | normalized matrix in `HermesRuntimeProvider.transitionalCapabilityMatrix`; each is `unsupported` and `assertTransitionalCapability` denies before a transport call | deferred / fail-closed |
|
||||||
| operator memory | `packages/memory/src/operator-memory-plugin.ts`, constructed by `apps/gateway/src/memory/memory.module.ts` and session-scoped by `apps/gateway/src/agent/agent.service.ts` | native Mosaic path |
|
| operator memory | `packages/memory/src/operator-memory-plugin.ts`, constructed by `apps/gateway/src/memory/memory.module.ts` and session-scoped by `apps/gateway/src/agent/agent.service.ts` | native Mosaic path |
|
||||||
| orchestration handoff | `InteractionCoordinationService` in `apps/gateway/src/coord/interaction-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | native Mosaic path |
|
| orchestration handoff | `apps/gateway/src/coord/mos-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | native Mosaic path |
|
||||||
| transcripts, profiles, preferences | no Hermes importer/schema mapping landed | no automatic migration |
|
| transcripts, profiles, preferences | no Hermes importer/schema mapping landed | no automatic migration |
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ Rollback is configuration/binding reversal, not a database rollback: no Hermes s
|
|||||||
2. Keep the gateway registration and core contracts unchanged unless a reviewed code rollback is required; `AgentRuntimeProviderRegistry` registration is explicit and non-replacing (`packages/agent/src/runtime-provider-registry.ts`).
|
2. Keep the gateway registration and core contracts unchanged unless a reviewed code rollback is required; `AgentRuntimeProviderRegistry` registration is explicit and non-replacing (`packages/agent/src/runtime-provider-registry.ts`).
|
||||||
3. Do not replay an unsupported Kanban, skills, memory, tools, or cron operation. The transitional matrix is intentionally fail-closed.
|
3. Do not replay an unsupported Kanban, skills, memory, tools, or cron operation. The transitional matrix is intentionally fail-closed.
|
||||||
4. Preserve Mosaic audit, session, and operator-memory records under their normal scoped retention rules; do not copy them into Hermes as a rollback shortcut.
|
4. Preserve Mosaic audit, session, and operator-memory records under their normal scoped retention rules; do not copy them into Hermes as a rollback shortcut.
|
||||||
5. For an in-flight coordination request, use the owned handoff observation/result flow in `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`); do not create a second orchestrator path.
|
5. For an in-flight coordination request, use the owned handoff observation/result flow in `MosCoordinationService`; do not create a second orchestrator path.
|
||||||
6. Capture the binding reversal, affected scope, correlation IDs, and reason in the approved operational record before retrying a cutover.
|
6. Capture the binding reversal, affected scope, correlation IDs, and reason in the approved operational record before retrying a cutover.
|
||||||
|
|||||||
@@ -20,29 +20,29 @@ interface CoordinationScope {
|
|||||||
readonly requesterAgentId: string; // trusted gateway/configuration data
|
readonly requesterAgentId: string; // trusted gateway/configuration data
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HandoffRequest {
|
interface MosHandoffRequest {
|
||||||
readonly idempotencyKey: string;
|
readonly idempotencyKey: string;
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
readonly context?: string;
|
readonly context?: string;
|
||||||
readonly missionId?: string;
|
readonly missionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HandoffReceipt {
|
interface MosHandoffReceipt {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly status: 'accepted' | 'queued';
|
readonly status: 'accepted' | 'queued';
|
||||||
readonly correlationId: string;
|
readonly correlationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Handoff {
|
interface MosHandoff {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly request: HandoffRequest;
|
readonly request: MosHandoffRequest;
|
||||||
readonly scope: CoordinationScope;
|
readonly scope: CoordinationScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InteractionCoordinationPort {
|
interface MosCoordinationPort {
|
||||||
handoff(handoff: Handoff): Promise<HandoffReceipt>;
|
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
||||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||||
}
|
}
|
||||||
@@ -58,20 +58,20 @@ and the requester agent from trusted authentication/configuration only.
|
|||||||
|
|
||||||
## Enforcement point
|
## Enforcement point
|
||||||
|
|
||||||
`apps/gateway` owns an `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) boundary that compares the
|
`apps/gateway` owns a `MosCoordinationService` boundary that compares the
|
||||||
trusted configured requester/target identities and rejects all of the following
|
trusted configured requester/target identities and rejects all of the following
|
||||||
before calling a transport: unconfigured requester, self-delegation, target
|
before calling a transport: unconfigured requester, self-delegation, target
|
||||||
identity drift, cross-tenant observe/result lookup, and attempts to observe or
|
identity drift, cross-tenant observe/result lookup, and attempts to observe or
|
||||||
receive a result for a handoff outside the originating tenant. The service exposes handoff, observe,
|
receive a result for a handoff outside the originating tenant. The service exposes handoff, observe,
|
||||||
and result only, and delegates delivery to an injected adapter.
|
and result only, and delegates delivery to an injected adapter.
|
||||||
|
|
||||||
M4 ships a native in-process `InMemoryInteractionCoordinationPort` as the concrete,
|
M4 ships a native in-process `InMemoryMosCoordinationPort` as the concrete,
|
||||||
deterministic adapter. It preserves the immutable handoff ID, tenant, requester
|
deterministic adapter. It preserves the immutable handoff ID, tenant, requester
|
||||||
identity, and correlation ID while demonstrating the handoff → observe → result
|
identity, and correlation ID while demonstrating the handoff → observe → result
|
||||||
round trip. It is a queue/port adapter, not a Mos-side consumer.
|
round trip. It is a queue/port adapter, not a Mos-side consumer.
|
||||||
|
|
||||||
A future fleet/tmux adapter is a documented M5 deployment seam and must
|
A future fleet/tmux adapter is a documented M5 deployment seam and must
|
||||||
implement the same `InteractionCoordinationPort`; no channel client or interaction
|
implement the same `MosCoordinationPort`; no channel client or interaction
|
||||||
runtime calls a transport directly.
|
runtime calls a transport directly.
|
||||||
|
|
||||||
## Required tests
|
## Required tests
|
||||||
|
|||||||
@@ -1,28 +1,3 @@
|
|||||||
# 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.
|
|
||||||
|
|||||||
@@ -33,18 +33,14 @@ Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service iden
|
|||||||
6. Every externally caused operation is replay-safe and correlated.
|
6. Every externally caused operation is replay-safe and correlated.
|
||||||
7. Provider capability absence is a denial, not an invitation to shell around it.
|
7. Provider capability absence is a denial, not an invitation to shell around it.
|
||||||
|
|
||||||
## Closed Prerequisite Findings
|
## Existing Findings That Block Tess
|
||||||
|
|
||||||
The original M1 findings below are closed by landed controls and retained for audit traceability.
|
- Command executor lacks server-side enforcement for declared scopes.
|
||||||
|
- Session list/reuse/destroy surfaces are not owner-filtered consistently.
|
||||||
|
- MCP schemas accept caller-supplied user identity.
|
||||||
|
- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists.
|
||||||
|
- Chat/tool persistence lacks mandatory redaction.
|
||||||
|
- Sessions/pending Discord output are in-memory and not restart-safe.
|
||||||
|
- Session GC currently performs globally scoped promotion.
|
||||||
|
|
||||||
| Former finding | Closed evidence |
|
These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled.
|
||||||
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| Command scope/role enforcement | `apps/gateway/src/commands/command-authorization.service.ts` and its authorization tests enforce the server-side approval boundary. |
|
|
||||||
| Cross-owner session access | Gateway session ownership tests cover server-derived owner and tenant scope. |
|
|
||||||
| Caller-controlled MCP identity | MCP tools derive actor and tenant from authenticated gateway context. |
|
|
||||||
| Missing Discord ingress allowlists | `apps/gateway/src/plugin/plugin.module.ts` requires the guild, channel, and user allowlist environment values; `apps/gateway/src/plugin/discord-ingress.security.spec.ts` exercises denial and configured ingress. |
|
|
||||||
| Missing redaction before persistence/egress | Gateway and log redaction coverage verifies sensitive content is classified before durable storage or channel delivery. |
|
|
||||||
| In-memory-only restart safety | `packages/agent/src/durable-session.test.ts` reconstructs durable identity, inbox/outbox, checkpoints, and handoffs after simulated restart. |
|
|
||||||
| Globally scoped session GC | `apps/gateway/src/gc/session-gc.service.spec.ts` verifies session-only collection and the absence of automatic global collection entry points. |
|
|
||||||
|
|
||||||
These controls remain subject to the runtime's independent review and release qualification gates.
|
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
# 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.
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
# Tess / Option 2 runtime-portability qualification — 2026-07-14
|
|
||||||
|
|
||||||
**Issue context:** #706–#711 and runtime-neutral Mos follow-up #754
|
|
||||||
|
|
||||||
**Qualified revision:** `d0771835542d` (`origin/main` at review time)
|
|
||||||
|
|
||||||
**Reviewer/runtime:** Independent Pi lane requested as `openai-codex/gpt-5.6-sol:high`
|
|
||||||
|
|
||||||
**Runtime resolution note:** Mosaic warned that `gpt-5.6-sol` was not present in the provider model catalog and proceeded with it as a custom model ID. This warning was part of the original qualification log and is material provenance; downstream claims must not treat catalog recognition as verified.
|
|
||||||
|
|
||||||
**Verdict:** REQUEST CHANGES
|
|
||||||
|
|
||||||
**Evidence type:** Point-in-time qualification; later commits and PR #757 must be reviewed separately
|
|
||||||
|
|
||||||
## Purpose and provenance
|
|
||||||
|
|
||||||
This report preserves the complete independent qualification that was previously available only in `/tmp/tess-option2-qualification.log`. It distinguishes passing component tests from the missing operational proof required for identity-continuous Mos failover.
|
|
||||||
|
|
||||||
No credential values, OAuth tokens, Discord tokens, device codes, or auth-file contents are included. Commands and results are retained so another environment can reproduce or challenge the findings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 1. Verdict
|
|
||||||
|
|
||||||
## **REQUEST CHANGES**
|
|
||||||
|
|
||||||
The current Option 2 implementation is a useful portability foundation, but it is **not qualified against AC-TESS-01..11** and is not equivalent to true same-Mos-identity failover.
|
|
||||||
|
|
||||||
Primary blockers:
|
|
||||||
|
|
||||||
1. **AC-TESS-01/02:** The required `mosaic tess` command does not exist; only `mosaic interaction` is registered (`packages/mosaic/src/commands/interaction.ts:60`). The cross-surface test proves CLI enrollment followed by Discord approval/stop, not bidirectional Discord/CLI chat streaming.
|
|
||||||
2. **AC-TESS-04:** Fleet/tmux and Matrix providers are implemented as libraries but are not registered in the production gateway. `AgentModule` registers only Hermes (`apps/gateway/src/agent/agent.module.ts:34`).
|
|
||||||
3. **Mos handoff is not operational or durable:** Production uses `InMemoryInteractionCoordinationPort` (`apps/gateway/src/coord/coord.module.ts:18`), with no Mos-side consumer. Restart loses handoff ownership, idempotency, activity, and results.
|
|
||||||
4. **AC-TESS-06/10:** Restart tests are good local persistence tests, but no real connector/harness failover or exercised rollback exists. Rollback is documentation-only.
|
|
||||||
5. **AC-TESS-08:** The parity suite validates a selected shared intersection using mocked transports. Matrix is not production-wired and tmux drops the runtime message idempotency key before delivery.
|
|
||||||
6. **AC-TESS-09:** M5 qualification remains `not-started`; no live Discord, Matrix homeserver, tmux/Mos consumer, Claude Code/Pi/Codex failover, or deployment rollback was tested.
|
|
||||||
7. **PR #750 mismatch:** Its description promises send-error coverage as HTTP 400, but both gateway and TUI test use HTTP 403 (`packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts:25-34`).
|
|
||||||
|
|
||||||
### AC disposition
|
|
||||||
|
|
||||||
| AC | Result | Evidence |
|
|
||||||
| --- | ----------------- | ------------------------------------------------------------------------------- |
|
|
||||||
| 01 | **Fail** | No `mosaic tess`; no bidirectional same-session chat/stream E2E |
|
|
||||||
| 02 | **Fail** | Generic CLI exists, but fleet/Matrix providers are unreachable in production |
|
|
||||||
| 03 | Pass | Pi profile/model/reasoning/effective-policy tests passed |
|
|
||||||
| 04 | **Fail** | No registered fleet provider or real Mos consumer |
|
|
||||||
| 05 | Partial | Hermes normalization/fail-closed matrix passes; live capability path is limited |
|
|
||||||
| 06 | Partial | PGlite restart/idempotency passes; no actual harness failover |
|
|
||||||
| 07 | Partial | Focused denial/replay tests pass; full M5 abuse qualification absent |
|
|
||||||
| 08 | Partial | Mocked shared-intersection parity passes; Matrix not operationally wired |
|
|
||||||
| 09 | **Fail** | Baselines/CI green, but required E2E/security/rollback qualification absent |
|
|
||||||
| 10 | **Fail** | Inventory incomplete/inconsistent; rollback not exercised |
|
|
||||||
| 11 | Pass/ledger stale | Documentation and sitemap exist; plugin/catalog ledger remains unresolved |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 2. Exact test commands and results
|
|
||||||
|
|
||||||
Initial focused attempts failed before collection because this detached worktree had no dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @mosaicstack/agent exec vitest run ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: startup failure, `Cannot find module 'vitest/config'`.
|
|
||||||
|
|
||||||
Setup used:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
corepack pnpm --store-dir /home/jarvis/.local/share/pnpm/store/v10 \
|
|
||||||
install --frozen-lockfile --ignore-scripts
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: PASS, 1,240 packages linked.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
corepack pnpm turbo run build \
|
|
||||||
--filter='@mosaicstack/gateway^...' \
|
|
||||||
--filter='@mosaicstack/mosaic^...'
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: **17/17 dependency builds successful**.
|
|
||||||
|
|
||||||
### Focused suites
|
|
||||||
|
|
||||||
```bash
|
|
||||||
corepack pnpm --filter @mosaicstack/agent exec vitest run \
|
|
||||||
src/runtime-provider-parity.test.ts \
|
|
||||||
src/matrix-native-runtime-provider.test.ts \
|
|
||||||
src/tmux-fleet-runtime-provider.test.ts \
|
|
||||||
src/durable-session.test.ts \
|
|
||||||
src/hermes-runtime-provider.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: **5 files, 39/39 tests passed**.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
corepack pnpm --filter @mosaicstack/gateway exec vitest run \
|
|
||||||
src/agent/durable-session.repository.test.ts \
|
|
||||||
src/__tests__/integration/tess-cross-surface.integration.test.ts \
|
|
||||||
src/plugin/discord-ingress.security.spec.ts \
|
|
||||||
src/coord/interaction-coordination.service.test.ts \
|
|
||||||
src/coord/interaction-coordination.routing.e2e.test.ts \
|
|
||||||
src/agent/hermes-runtime-reachability.e2e.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: **6 files, 36/36 tests passed**. PGlite close/reopen recovery passed in 504 ms.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
corepack pnpm --filter @mosaicstack/mosaic exec vitest run \
|
|
||||||
src/fleet/matrix-native-runtime-transport.test.ts \
|
|
||||||
src/fleet/tess-service-profile.test.ts \
|
|
||||||
src/commands/interaction.test.ts \
|
|
||||||
src/tui/gateway-api.interaction-errors.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: **4 files, 15/15 tests passed**.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
corepack pnpm --filter @mosaicstack/coord exec vitest run \
|
|
||||||
src/__tests__/interaction-coordination.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: **1 file, 7/7 tests passed**.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
corepack pnpm --filter @mosaicstack/gateway exec vitest run \
|
|
||||||
src/agent/interaction.controller.test.ts \
|
|
||||||
src/commands/command-authorization.service.spec.ts \
|
|
||||||
src/agent/__tests__/runtime-provider-registry.service.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: **3 files, 27/27 tests passed**.
|
|
||||||
|
|
||||||
Focused total: **124/124 tests passed** after dependency setup.
|
|
||||||
|
|
||||||
### Baselines
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TURBO_FORCE=true corepack pnpm typecheck
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: **42/42 tasks successful**.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TURBO_FORCE=true corepack pnpm lint
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: **23/23 tasks successful**.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
corepack pnpm format:check
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: **PASS — all files matched Prettier style**.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
|
|
||||||
-r mosaicstack/stack -n 1796
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: **SUCCESS** at `d0771835542d`; all test, build, sanitization, typecheck, lint, format, and publish steps green.
|
|
||||||
|
|
||||||
No tracked files outside the pre-existing `.mosaic/orchestrator/*` launcher changes were modified.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 3. Stale ledger inconsistencies
|
|
||||||
|
|
||||||
1. `docs/tess/MISSION-MANIFEST.md` still says:
|
|
||||||
- current milestone M1;
|
|
||||||
- progress 0/5;
|
|
||||||
- M2/M3/M5 not started.
|
|
||||||
2. `docs/tess/TASKS.md` says:
|
|
||||||
- M4-V failed;
|
|
||||||
- M4-W-001 and TESS-PLG-001 in progress;
|
|
||||||
- M5-V not started.
|
|
||||||
3. Provider issue state conflicts:
|
|
||||||
- #707–#709 remain open although M1–M3 rows are recorded done/pass.
|
|
||||||
- #710 and #711 are closed although M4-V failed and M5-V is not started.
|
|
||||||
4. M5 work was marked done despite depending on failed M4-V.
|
|
||||||
5. TESS-M3-002 says `mosaic tess` is done, but only `mosaic interaction` exists.
|
|
||||||
6. PR #750 removed stale service references from operational docs, but `docs/tess/TASKS.md` still contains `MosCoordinationService` in historical notes.
|
|
||||||
7. `docs/tess/MIGRATION-INVENTORY.md` remains an “initial inventory” with several capabilities marked `adapt`; `M5-MIGRATION-INVENTORY.md` marks grouped capabilities deferred/fail-closed. Neither supplies the complete owner/evidence matrix AC-TESS-10 requires.
|
|
||||||
8. TESS-M2-FUP-001 remains real: the unkeyed SHA-256 compatibility branch still exists at `durable-session.repository.ts:427-431`.
|
|
||||||
9. TESS-PLG-001 claims catalog registration was folded into W-001, but production evidence shows provider registration in the gateway—not a completed `packages/mosaic` plugin catalog.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 4. Gap to true same-Mos-identity failover
|
|
||||||
|
|
||||||
Current code can relaunch the same roster name under another runtime and can rebind a durable interaction session to another provider/runtime ID. That is **replacement**, not identity-continuous failover.
|
|
||||||
|
|
||||||
Missing pieces:
|
|
||||||
|
|
||||||
- No canonical logical Mos identity independent of harness-native session IDs.
|
|
||||||
- No exclusive connector lease or monotonic fencing epoch; session rebinding is effectively last-write-wins.
|
|
||||||
- No stale-holder rejection preventing the old harness from continuing side effects.
|
|
||||||
- No normalized Claude Code/Pi/Codex checkpoint/import/export adapters.
|
|
||||||
- No durable Mos coordination transport or Mos consumer.
|
|
||||||
- No canonical handoff containing mission/task refs, git state, causal sequence, pending operations, capability requirements, and acknowledgements.
|
|
||||||
- No end-to-end receipt journal across connectors.
|
|
||||||
- Matrix has deterministic transaction IDs, but tmux delivery discards `RuntimeMessage.idempotencyKey`.
|
|
||||||
- No fault-injection test transferring Mos among Claude Code, Pi, and Codex and then rolling back.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 5. Minimal follow-up issue decomposition
|
|
||||||
|
|
||||||
| Order | Issue | Minimum acceptance criteria |
|
|
||||||
| ----- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 1 | **Logical identity and security fencing** | Server-derived `{tenant, logicalAgentId, connectorId, harness, leaseEpoch, scopes, expiry}`; signed/fenced execution grant; stale/forged/cross-tenant grants denied and audited; no connector credential in handoffs |
|
|
||||||
| 2 | **Durable connector lease** | PostgreSQL-backed exclusive lease with CAS, monotonic epoch, TTL/heartbeat, explicit takeover, and gateway rejection of stale holders; connectors for Claude Code, Pi, and Codex |
|
|
||||||
| 3 | **Canonical handoff/checkpoint** | Versioned, sealed schema containing canonical mission/task/git references, checkpoint digest, causal sequence, required capabilities, pending/ambiguous operation references, and source/destination acknowledgement; no raw secrets or mandatory harness transcript |
|
|
||||||
| 4 | **Exactly-once connector journal** | Durable operation IDs and receipts; idempotency propagated through every adapter; Matrix transaction mapping; tmux replaced or wrapped with receiver-side durable dedupe; ambiguous effects remain held for authorized reconciliation |
|
|
||||||
| 5 | **Cross-harness failover and rollback E2E** | Real Mos identity moves Claude Code → Pi → Codex and back; inject crashes before/after lease transfer, handoff persistence, send, and acknowledgement; stale connector fenced; no duplicate side effects; canonical state preserved; rollback evidence published |
|
|
||||||
| 6 | **Generic gateway research ADR** | Evaluate LiteLLM subscription OAuth and Bifrost concepts without adding either to core; include terms/security review, credential lifecycle, tenant mapping, budgets, failover semantics, and adapter-only prototype |
|
|
||||||
|
|
||||||
## Generic gateway placement
|
|
||||||
|
|
||||||
Allowed topology:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Discord / CLI / web
|
|
||||||
↓
|
|
||||||
Mosaic Gateway: auth, tenant scope, policy, approvals, audit
|
|
||||||
↓
|
|
||||||
IProviderAdapter / AgentRuntimeProvider
|
|
||||||
↓
|
|
||||||
optional LiteLLM or Bifrost egress proxy
|
|
||||||
↓
|
|
||||||
upstream provider
|
|
||||||
```
|
|
||||||
|
|
||||||
- **LiteLLM ChatGPT subscription OAuth:** research-only, opt-in, behind an adapter. Subscription credentials require explicit terms, revocation, scope, token-storage, and audit review. They must never become Mosaic identity or core configuration.
|
|
||||||
- **Bifrost:** virtual keys are downstream proxy credentials, not Mosaic principals. Budget and failover concepts may inform Mosaic routing, but tenant policy, authorization, and audit remain in Mosaic.
|
|
||||||
- Neither product may introduce schemas into Mosaic core, receive direct calls from channels/agents, or bypass `IProviderAdapter`/`AgentRuntimeProvider`.
|
|
||||||
- Mosaic should also correct its existing “all providers unhealthy → use one anyway” fallback behavior before adopting more automatic failover (`routing-engine.service.ts:204-212`).
|
|
||||||
@@ -28,7 +28,6 @@ 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',
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
attachInteractionSession,
|
|
||||||
sendInteractionMessage,
|
|
||||||
stopInteractionSession,
|
|
||||||
} from './gateway-api.js';
|
|
||||||
|
|
||||||
const gateway = 'https://gateway.example.test';
|
|
||||||
const cookie = 'session=test';
|
|
||||||
const base = { agentName: 'Nova', correlationId: 'corr-1', sessionId: 'session-1' };
|
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
|
||||||
|
|
||||||
/** Unit coverage: the TUI preserves a non-success gateway response in its CLI error. */
|
|
||||||
describe('interaction gateway error mapping', (): void => {
|
|
||||||
it.each([
|
|
||||||
{
|
|
||||||
name: 'attach unauthorized',
|
|
||||||
response: { status: 401, message: 'Invalid or expired session' },
|
|
||||||
invoke: (): Promise<unknown> => attachInteractionSession(gateway, cookie, base),
|
|
||||||
expected:
|
|
||||||
'Failed to attach interaction session (401): {"message":"Invalid or expired session"}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'send invalid request',
|
|
||||||
response: { status: 403, message: 'Content and idempotency key are required' },
|
|
||||||
invoke: (): Promise<unknown> =>
|
|
||||||
sendInteractionMessage(gateway, cookie, {
|
|
||||||
...base,
|
|
||||||
content: 'hello',
|
|
||||||
idempotencyKey: 'message-1',
|
|
||||||
}),
|
|
||||||
expected:
|
|
||||||
'Failed to send interaction message (403): {"message":"Content and idempotency key are required"}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'stop forbidden',
|
|
||||||
response: { status: 403, message: 'Runtime termination approval denied' },
|
|
||||||
invoke: (): Promise<unknown> =>
|
|
||||||
stopInteractionSession(gateway, cookie, { ...base, approvalRef: 'approval-1' }),
|
|
||||||
expected:
|
|
||||||
'Failed to stop interaction session (403): {"message":"Runtime termination approval denied"}',
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'$name preserves the typed gateway denial in the CLI error',
|
|
||||||
async ({ response, invoke, expected }) => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn(
|
|
||||||
async () =>
|
|
||||||
new Response(JSON.stringify({ message: response.message }), {
|
|
||||||
status: response.status,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(invoke()).rejects.toThrow(expected);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/** 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;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './channel-adapter.js';
|
|
||||||
export * from './channel.dto.js';
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { ChannelAttachmentDto } from '../channel/index.js';
|
|
||||||
import type {
|
import type {
|
||||||
CommandManifestPayload,
|
CommandManifestPayload,
|
||||||
SlashCommandApprovalResultPayload,
|
SlashCommandApprovalResultPayload,
|
||||||
@@ -74,7 +73,6 @@ 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 */
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
# @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
|
|
||||||
```
|
|
||||||
@@ -19,16 +19,13 @@
|
|||||||
"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 --coverage",
|
"test": "vitest run --passWithNoTests"
|
||||||
"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"
|
||||||
|
|||||||
@@ -1,950 +0,0 @@
|
|||||||
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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,38 +1,10 @@
|
|||||||
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
import { ChannelDeliveryError } from '@mosaicstack/types';
|
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
||||||
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). */
|
||||||
@@ -58,23 +30,13 @@ 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[]>
|
||||||
> = {
|
> = {
|
||||||
@@ -128,7 +90,6 @@ 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 ||
|
||||||
@@ -169,7 +130,6 @@ 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,
|
||||||
@@ -194,7 +154,6 @@ 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 {
|
||||||
@@ -270,25 +229,16 @@ export function verifyDiscordIngressEnvelope(
|
|||||||
return envelope.payload;
|
return envelope.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort {
|
export class DiscordPlugin {
|
||||||
readonly name = 'discord';
|
|
||||||
|
|
||||||
private client: Client;
|
private client: Client;
|
||||||
private socket: Socket | null = null;
|
private socket: Socket | null = null;
|
||||||
/** Bounded last-authorized routes for response-target egress validation. */
|
/** Map Discord channel ID → Mosaic conversation ID. */
|
||||||
private conversationRoutes = new Map<string, ChannelConversationRouteDto>();
|
private channelConversations = new Map<string, string>();
|
||||||
/** 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(
|
constructor(private readonly config: DiscordPluginConfig) {
|
||||||
private readonly config: DiscordPluginConfig,
|
this.client = new Client({
|
||||||
private readonly dependencies: DiscordPluginDependencies = {},
|
|
||||||
) {
|
|
||||||
this.client =
|
|
||||||
dependencies.client ??
|
|
||||||
new Client({
|
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
@@ -299,8 +249,7 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
const socketFactory = this.dependencies.socketFactory ?? io;
|
this.socket = io(`${this.config.gatewayUrl}/chat`, {
|
||||||
this.socket = socketFactory(`${this.config.gatewayUrl}/chat`, {
|
|
||||||
auth: { discordServiceToken: this.config.serviceToken },
|
auth: { discordServiceToken: this.config.serviceToken },
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
});
|
});
|
||||||
@@ -327,13 +276,11 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
|
|||||||
|
|
||||||
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.sendAgentResponse(data.conversationId, text).catch((err: unknown) => {
|
this.pendingResponses.delete(data.conversationId);
|
||||||
|
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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,33 +288,15 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
|
|||||||
this.pendingResponses.set(data.conversationId, '');
|
this.pendingResponses.set(data.conversationId, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('error', (data: { conversationId?: unknown }) => {
|
this.client.on('messageCreate', (message: DiscordMessage) =>
|
||||||
if (typeof data.conversationId !== 'string') return;
|
this.handleDiscordMessage(message),
|
||||||
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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
await this.client.login(this.config.token);
|
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> {
|
||||||
@@ -375,14 +304,6 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
|
|||||||
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;
|
||||||
@@ -404,224 +325,77 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
|
|||||||
topic: project.description ?? `Mosaic project: ${project.name}`,
|
topic: project.description ?? `Mosaic project: ${project.name}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// A project channel has no logical-agent conversation until a configured
|
this.channelConversations.set(channel.id, `discord-${channel.id}`);
|
||||||
// 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 | Promise<void> {
|
private handleDiscordMessage(message: DiscordMessage): void {
|
||||||
if (message.author.bot || !this.client.user || !message.guildId) return;
|
if (message.author.bot || !this.client.user) 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);
|
||||||
const content = isMention
|
if (!isMention) return;
|
||||||
? 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 rateKey = `${message.guildId}:${authorizationChannelId}:${message.author.id}`;
|
const content = message.content
|
||||||
if (
|
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
|
||||||
!this.consumeRateLimit(
|
.trim();
|
||||||
this.messageRateWindows,
|
if (!content) return;
|
||||||
rateKey,
|
if (!this.socket?.connected) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approval/stop commands act on the current durable session. They remain
|
const channelId = message.channelId;
|
||||||
// at the current channel/thread target rather than creating a new topic.
|
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
|
||||||
const route = this.resolveConversationRoute(
|
const bindingChannelId = parentChannelId ?? channelId;
|
||||||
message,
|
const binding = resolveDiscordInteractionBinding(
|
||||||
binding,
|
this.config.interactionBindings ?? [],
|
||||||
authorizationChannelId,
|
message.guildId,
|
||||||
createThread,
|
bindingChannelId,
|
||||||
|
message.author.id,
|
||||||
|
'send',
|
||||||
);
|
);
|
||||||
if (route instanceof Promise) {
|
if (!binding) return;
|
||||||
return route.then((resolved: ChannelConversationRouteDto): void | Promise<void> =>
|
const conversationId =
|
||||||
this.dispatchDiscordIngress(message, content, operation, binding, resolved),
|
this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`;
|
||||||
);
|
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: ingress.correlationId,
|
correlationId: randomUUID(),
|
||||||
messageId: ingress.nativeMessageId,
|
messageId: message.id,
|
||||||
guildId: String(ingress.message.metadata['guildId'] ?? ''),
|
guildId: message.guildId,
|
||||||
channelId: ingress.route.authorizationChannelId,
|
channelId: bindingChannelId,
|
||||||
userId: ingress.principal.channelUserId,
|
userId: message.author.id,
|
||||||
conversationId: ingress.route.conversationId,
|
conversationId,
|
||||||
content: ingress.message.content,
|
content,
|
||||||
...(ingress.route.responseTarget.threadId
|
threadId: parentChannelId ? channelId : undefined,
|
||||||
? { threadId: ingress.route.responseTarget.threadId }
|
attachments: Array.from(message.attachments.values()).map((attachment) => ({
|
||||||
: {}),
|
|
||||||
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.mimeType,
|
contentType: attachment.contentType,
|
||||||
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
this.config.serviceToken,
|
this.config.serviceToken,
|
||||||
);
|
);
|
||||||
socket.emit(
|
this.socket.emit(
|
||||||
ingress.operation === 'approval.create'
|
/^\/approve$/i.test(content)
|
||||||
? 'discord:approve'
|
? 'discord:approve'
|
||||||
: ingress.operation === 'session.stop'
|
: content.startsWith('/stop ')
|
||||||
? 'discord:stop'
|
? 'discord:stop'
|
||||||
: 'message',
|
: 'message',
|
||||||
envelope,
|
envelope,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private authorizationChannelId(message: DiscordMessage): string {
|
private isAllowedMessage(message: DiscordMessage): boolean {
|
||||||
// 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) &&
|
||||||
@@ -630,282 +404,31 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveConversationRoute(
|
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
|
||||||
message: DiscordMessage,
|
const channelId = Array.from(this.channelConversations.entries()).find(
|
||||||
binding: DiscordInteractionBinding,
|
([, convId]) => convId === conversationId,
|
||||||
authorizationChannelId: string,
|
)?.[0];
|
||||||
createThread: boolean,
|
|
||||||
): ChannelConversationRouteDto | Promise<ChannelConversationRouteDto> {
|
if (!channelId) {
|
||||||
if (authorizationChannelId !== message.channelId) {
|
console.error(`[discord] No channel found for conversation ${conversationId}`);
|
||||||
return this.createConversationRoute(
|
return;
|
||||||
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)) {
|
||||||
throw new ChannelDeliveryError(
|
console.error(
|
||||||
'destination_unavailable',
|
`[discord] Channel ${channelId} not sendable for conversation ${conversationId}`,
|
||||||
`Discord destination is unavailable for conversation ${egress.route.conversationId}`,
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
await channel.send({ content: chunk, nonce, enforceNonce: true });
|
|
||||||
return;
|
return;
|
||||||
} 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 {
|
for (const chunk of this.chunkText(text, 1900)) {
|
||||||
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 {
|
try {
|
||||||
await this.send({
|
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
|
||||||
correlationId: randomUUID(),
|
} catch (err: unknown) {
|
||||||
route,
|
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
|
||||||
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[] {
|
||||||
|
|||||||
@@ -4,15 +4,5 @@ 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
41
pnpm-lock.yaml
generated
@@ -732,9 +732,6 @@ 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
|
||||||
@@ -742,9 +739,6 @@ 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
|
||||||
@@ -1175,11 +1169,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.hirok.io'
|
deprecated: 'Merged into tsx: https://tsx.is'
|
||||||
|
|
||||||
'@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.hirok.io'
|
deprecated: 'Merged into tsx: https://tsx.is'
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.21.5':
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||||
@@ -2166,57 +2160,47 @@ 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==}
|
||||||
@@ -3931,7 +3915,6 @@ 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==}
|
||||||
@@ -4112,7 +4095,6 @@ 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==}
|
||||||
@@ -7105,7 +7087,6 @@ 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:
|
||||||
@@ -10996,24 +10977,6 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user