Compare commits

..

36 Commits

Author SHA1 Message Date
Hermes Agent
365c2130de feat(#756): add official Discord channel adapter
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-14 15:16:44 -05:00
49e8a54105 docs(#751): Publish native Kanban/SOT canon (#752)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 17:08:09 +00:00
d077183554 docs(tess): remediate M5 qualification findings (#750)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 19:59:38 +00:00
405984af5a De-hardcode orchestrator and interaction agent names (#748)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 18:59:27 +00:00
8dd4e9d541 docs(tess): ledger sync m4 — M5-003 done, #745/#746 merged (#749)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 18:14:24 +00:00
bc8016c831 docs(#744): complete Tess documentation gate (#746)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 17:44:17 +00:00
e72388b2cb docs(tess): ledger sync m3 — M5-001 + M5-002 done (#745)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 16:14:23 +00:00
6345dbfcf2 feat(agent): add Matrix native runtime provider (#744)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 15:29:37 +00:00
c6e3cfbd95 docs(tess): ledger sync — W-001 3-of-3 merged, M5-002 approved, M5-001 in TDD (#743)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-13 15:29:33 +00:00
5789711ee0 docs(tess): add migration evidence set (#742)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 15:14:29 +00:00
f40e6ba388 docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending) (#741)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 15:00:15 +00:00
b7b0f508e6 feat(gateway): register Hermes runtime provider (#740)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 14:45:08 +00:00
3378b857eb feat(memory): bind operator plugin to agent sessions (#739)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 13:44:20 +00:00
e2376190e5 feat(gateway): expose Mos coordination boundary (#737)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-13 13:14:44 +00:00
cca6aaf947 feat(agent): add Hermes transitional capability matrix (#738)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-13 13:14:43 +00:00
2363f155b4 feat(memory): add operator retrieval plugin (#736)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 12:44:31 +00:00
76325ca3f2 feat(tess): add Mos coordination boundary (#735)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 11:59:16 +00:00
9e5b9188ce feat(agent): add transitional Hermes runtime adapter (#734)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 11:29:27 +00:00
f1c6b37b46 fix(tess): route bare Discord approvals (#733)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 10:29:10 +00:00
0b621660c8 feat(tess): wire durable interaction surfaces (#732)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 10:05:29 +00:00
84d884b932 feat(#709): add configured Discord interaction binding (#730)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 09:02:45 +00:00
8246ee0137 feat(tess): add generic interaction CLI (#731)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 05:52:41 +00:00
99a2d0fc9d feat(tess): persist durable session state (#729)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 05:14:11 +00:00
e3b5113be2 feat(tess): add configurable Pi interaction service (#728)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 02:59:27 +00:00
24b07d0f83 docs(tess): sync M1 ledger to merged state; M1-V Mos-owned (#727)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 02:14:13 +00:00
86a50138a9 feat(tess): add safe runtime observability (#726)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 01:29:18 +00:00
7b9f40d3b7 fix(tess): redact chat persistence and egress (#725)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 01:14:16 +00:00
9a8a572fcf feat(tess): add roster-bound tmux fleet provider (#724)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 00:59:24 +00:00
753a360517 fix(#707): scope session GC retention (#720)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 00:44:19 +00:00
e92186d768 feat: add Tess runtime provider registry (#722)
All checks were successful
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 23:53:54 +00:00
119f64e69d feat(#707): secure Discord service ingress (#716)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 23:18:29 +00:00
46ca3ce742 fix(tess): enforce command authorization approvals (#718)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-12 23:18:01 +00:00
227b73fcdf fix(security): enforce Tess MCP server identity (#717)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 22:49:00 +00:00
a959b1d6b4 ci: restrict push-event CI to protected branches (halve feature-branch load) (#721)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-12 22:48:58 +00:00
62f8177806 feat(tess): define runtime provider contract (#719)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-12 22:29:21 +00:00
353e43c947 fix: enforce Tess session ownership scope (#715)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-12 22:14:17 +00:00
197 changed files with 39039 additions and 632 deletions

View File

@@ -7,7 +7,14 @@ variables:
- &enable_pnpm 'corepack enable'
when:
- event: [push, pull_request, manual]
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
# push CI is restricted to protected branches (main) so a feature-branch push no
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
# CI load on the storage-constrained runner with zero loss of gating (branch
# protection requires no push/ci status context; main still gets full push CI).
- event: [pull_request, manual]
- event: push
branch: main
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).

View File

@@ -31,6 +31,7 @@
"@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@mosaicstack/agent": "workspace:^",
"@mosaicstack/auth": "workspace:^",
"@mosaicstack/brain": "workspace:^",
"@mosaicstack/config": "workspace:^",

View File

@@ -0,0 +1,194 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
import {
createDiscordIngressEnvelope,
DiscordPlugin,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import { InteractionController } from '../../agent/interaction.controller.js';
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
import { DurableSessionService } from '../../agent/durable-session.service.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
const SERVICE_TOKEN = 'test-discord-service-token';
const envKeys = [
'DISCORD_SERVICE_TOKEN',
'DISCORD_SERVICE_TENANT_ID',
'DISCORD_INTERACTION_BINDINGS',
'DISCORD_ALLOWED_GUILD_IDS',
'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
] as const;
const priorEnv = new Map<string, string | undefined>();
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
return {
content,
messageId,
correlationId,
guildId: 'guild-1',
channelId: 'channel-1',
userId: 'discord-admin-1',
conversationId: 'Nova:discord:channel-1',
};
}
function authorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
return new CommandAuthorizationService(
{
select: () => ({
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
}),
} as never,
{
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => entries.set(key, value),
del: async (key: string) => Number(entries.delete(key)),
},
);
}
describe('interaction Discord/CLI durable-session integration', () => {
afterEach(() => {
for (const key of envKeys) {
const value = priorEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
priorEnv.clear();
});
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
for (const key of envKeys) priorEnv.set(key, process.env[key]);
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
},
},
]);
const durable = new DurableSessionService(
new InMemoryDurableSessionStore() as never,
{} as never,
);
const enrollmentRuntime = {
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
};
const controller = new InteractionController(enrollmentRuntime as never, durable);
await controller.enroll(
'Nova',
'Nova:discord:channel-1',
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
'cli-enrollment-correlation',
);
const authz = authorization();
const terminated = vi.fn().mockResolvedValue(undefined);
const runtime = new RuntimeProviderService(
{
require: () => ({
capabilities: async () => ({ supported: ['session.terminate'] }),
terminate: terminated,
}),
} as never,
{ record: async () => undefined } as never,
{
consume: (approvalId, action) =>
authz.consumeRuntimeTerminationApproval(approvalId, action),
},
);
const gateway = new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
authz,
runtime,
durable,
);
const client = { data: { discordService: true }, emit: vi.fn() };
const plugin = new DiscordPlugin({
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-1'],
allowedChannelIds: ['channel-1'],
allowedUserIds: ['discord-admin-1'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
},
},
],
});
const pluginInternals = plugin as unknown as {
client: { user: { id: string } };
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
handleDiscordMessage(message: unknown): void;
};
const pluginSocket = { connected: true, emit: vi.fn() };
pluginInternals.client = { user: { id: 'bot-1' } };
pluginInternals.socket = pluginSocket;
pluginInternals.handleDiscordMessage({
id: 'approve-1',
guildId: 'guild-1',
channelId: 'channel-1',
author: { id: 'discord-admin-1', bot: false },
mentions: { has: () => true },
content: '<@bot-1> /approve',
channel: { parentId: null },
attachments: new Map(),
});
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
success: boolean;
};
expect(approval.success).toBe(true);
await gateway.handleDiscordStop(
client as never,
createDiscordIngressEnvelope(
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
SERVICE_TOKEN,
),
);
expect(terminated).toHaveBeenCalledWith(
'runtime-1',
approval.approvalId,
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
);
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
correlationId: 'discord-stop-correlation',
success: true,
});
});
});

View File

@@ -20,7 +20,7 @@ export class AdminHealthController {
async check(): Promise<HealthStatusDto> {
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
const sessions = this.agentService.listSessions();
const sessions = this.agentService.listAllSessionsForSystem();
const providers = this.providerService.listProviders();
const allOk = database.status === 'ok' && cache.status === 'ok';

View File

@@ -0,0 +1,191 @@
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { AgentService, type AgentSession } from '../agent.service.js';
import type { ActorTenantScope } from '../../auth/session-scope.js';
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
type AgentServiceInternals = {
sessions: Map<string, AgentSession>;
creating: Map<string, Promise<AgentSession>>;
};
function makeService(operatorMemory: unknown = null): AgentService {
return new AgentService(
{
getDefaultModel: vi.fn(() => null),
getRegistry: vi.fn(() => ({})),
findModel: vi.fn(),
listAvailableModels: vi.fn(() => []),
} as never,
{} as never,
{} as never,
{ available: false } as never,
{} as never,
{ getToolDefinitions: vi.fn(() => []) } as never,
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
);
}
function internals(service: AgentService): AgentServiceInternals {
return service as unknown as AgentServiceInternals;
}
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp/tess-session-ownership-test',
allowedTools: null,
userId: scope.userId,
tenantId: scope.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
describe('AgentService owner/tenant scope enforcement', () => {
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
const service = makeService();
const session = makeSession();
internals(service).sessions.set(CONVERSATION_ID, session);
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
expect(session.channels.has('websocket:owner')).toBe(true);
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() =>
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
expect(session.modelId).toBe('owner-model');
expect(() =>
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
expect(session.agentConfigId).toBe('agent-owner');
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
cleanup();
await expect(
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
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(
ForbiddenException,
);
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
expect(session.piSession.dispose).toHaveBeenCalled();
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
});
it('derives the operator-memory scope on the createSession production path', async () => {
const plugin = { capture: vi.fn(), search: vi.fn() };
const service = makeService(plugin);
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]);
// Session construction reaches the real scope derivation before the intentionally incomplete
// Pi test double rejects later in createAgentSession.
await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined);
expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, {
tenantId: OWNER_SCOPE.tenantId,
ownerId: OWNER_SCOPE.userId,
sessionId: CONVERSATION_ID,
});
});
it('denies a foreign actor before it can obtain another session operator-memory scope', async () => {
const plugin = { capture: vi.fn(), search: vi.fn() };
const service = makeService(plugin);
internals(service).sessions.set(CONVERSATION_ID, makeSession());
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox');
await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(buildTools).not.toHaveBeenCalled();
expect(plugin.capture).not.toHaveBeenCalled();
expect(plugin.search).not.toHaveBeenCalled();
});
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
const service = makeService();
const session = makeSession();
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
await expect(
service.createSession(CONVERSATION_ID, {
userId: FOREIGN_SCOPE.userId,
tenantId: FOREIGN_SCOPE.tenantId,
}),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
service.createSession(CONVERSATION_ID, {
userId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
}),
).resolves.toBe(session);
});
});

View File

@@ -0,0 +1,370 @@
import { describe, expect, it } from 'vitest';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import type { ActorTenantScope } from '../../auth/session-scope.js';
import {
RuntimeProviderAuditService,
RuntimeProviderService,
type RuntimeAuditEvent,
type RuntimeAuditSink,
type RuntimeApprovalVerifier,
} from '../runtime-provider-registry.service.js';
process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
const CONTEXT = {
actorScope: OWNER_SCOPE,
channelId: 'cli',
correlationId: 'correlation-1',
};
class RecordingRuntimeProvider implements AgentRuntimeProvider {
readonly id = 'fleet';
readonly receivedScopes: RuntimeScope[] = [];
readonly sentMessages: RuntimeMessage[] = [];
terminateCalls = 0;
throwAfterSend = false;
throwAuthorization = false;
constructor(private readonly supported: RuntimeCapability[]) {}
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
this.receivedScopes.push(scope);
return { supported: this.supported };
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
this.receivedScopes.push(scope);
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
this.receivedScopes.push(scope);
return [];
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
this.receivedScopes.push(scope);
return [];
}
async *streamSession(
_sessionId: string,
_cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
this.receivedScopes.push(scope);
return;
}
async sendMessage(
_sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
this.receivedScopes.push(scope);
this.sentMessages.push(message);
if (this.throwAuthorization) {
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
}
if (this.throwAfterSend) {
throw new Error('provider acknowledgement failed');
}
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
this.receivedScopes.push(scope);
return {
attachmentId: 'attachment-1',
sessionId,
mode,
expiresAt: '2026-07-12T00:00:00.000Z',
};
}
async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
}
async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
this.terminateCalls += 1;
}
}
class RecordingAuditSink implements RuntimeAuditSink {
readonly events: RuntimeAuditEvent[] = [];
async record(event: RuntimeAuditEvent): Promise<void> {
this.events.push(event);
}
}
class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
async consume(): Promise<boolean> {
return false;
}
}
class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;
async consume(
_approvalRef: string,
action: Parameters<RuntimeApprovalVerifier['consume']>[1],
): Promise<boolean> {
this.consumedAction = action;
return true;
}
}
function makeService(
provider: RecordingRuntimeProvider,
audit: RuntimeAuditSink = new RecordingAuditSink(),
approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
): RuntimeProviderService {
const registry = new AgentRuntimeProviderRegistry();
registry.register(provider);
return new RuntimeProviderService(registry, audit, approval);
}
describe('RuntimeProviderService security boundary', (): void => {
it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
);
const providerScope = provider.receivedScopes[0];
expect(providerScope).toEqual({
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
});
expect(Object.isFrozen(providerScope)).toBe(true);
expect(audit.events).toContainEqual(
expect.objectContaining({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
resourceId: 'session-1',
durationMs: expect.any(Number),
}),
);
expect(JSON.stringify(audit.events)).not.toContain('hello');
expect(JSON.stringify(audit.events)).not.toContain('key-1');
});
it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let persisted: unknown;
const durableAudit = new RuntimeProviderAuditService({
logs: {
ingest: async (entry: unknown): Promise<unknown> => {
persisted = entry;
return entry;
},
},
} as never);
const service = makeService(provider, durableAudit);
await service.sendMessage(
'fleet',
'session/credential-canary=secret-value',
{ content: 'safe message', idempotencyKey: 'key-1' },
CONTEXT,
);
expect(provider.sentMessages).toHaveLength(1);
expect(JSON.stringify(persisted)).not.toContain('secret-value');
});
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider([]);
const service = makeService(provider);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/capability denied/);
expect(provider.sentMessages).toEqual([]);
});
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new DenyingApprovalVerifier();
const audit = new RecordingAuditSink();
const service = makeService(provider, audit, approval);
await expect(
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
).rejects.toThrow(/approval denied/);
expect(provider.terminateCalls).toBe(0);
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
});
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new AcceptingApprovalVerifier();
const service = makeService(provider, new RecordingAuditSink(), approval);
await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);
expect(approval.consumedAction).toEqual({
providerId: 'fleet',
sessionId: 'session-1',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
agentName: process.env['MOSAIC_AGENT_NAME'],
});
expect(provider.terminateCalls).toBe(1);
});
it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const unavailableAudit: RuntimeAuditSink = {
async record(): Promise<void> {
throw new Error('audit unavailable');
},
};
const service = makeService(provider, unavailableAudit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/audit unavailable/);
expect(provider.sentMessages).toEqual([]);
});
it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAfterSend = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider acknowledgement failed/);
expect(provider.sentMessages).toHaveLength(1);
expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
'requested',
'failed',
]);
expect(audit.events.at(-1)).toMatchObject({
errorCode: 'provider_error',
durationMs: expect.any(Number),
});
});
it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAuthorization = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider authorization denied/);
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
});
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
let persisted: unknown;
const ingest = async (entry: unknown): Promise<unknown> => {
persisted = entry;
return entry;
};
const service = new RuntimeProviderAuditService({ logs: { ingest } } as never);
await service.record({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: 'owner-1',
tenantId: 'tenant-1',
channelId: 'cli',
correlationId: 'correlation-1',
resourceId: 'session-1',
durationMs: 12,
});
expect(persisted).toMatchObject({
content: 'runtime.provider.audit',
metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }),
});
expect(JSON.stringify(persisted)).not.toContain('approval');
});
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let auditCalls = 0;
const audit: RuntimeAuditSink = {
async record(): Promise<void> {
auditCalls += 1;
if (auditCalls === 2) {
throw new Error('completion audit unavailable');
}
},
};
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).resolves.toBeUndefined();
expect(provider.sentMessages).toHaveLength(1);
expect(auditCalls).toBe(2);
});
});

View File

@@ -0,0 +1,262 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
vi.mock('../../commands/command-executor.service.js', () => ({
CommandExecutorService: class CommandExecutorService {},
}));
vi.mock('../routing/routing-engine.service.js', () => ({
RoutingEngineService: class RoutingEngineService {},
}));
import { SessionsController } from '../sessions.controller.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
promptCount: 0,
channels: [],
durationMs: 0,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
...overrides,
};
}
function makeAgentSession(owner = USER_A): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp',
allowedTools: null,
userId: owner.id,
tenantId: owner.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
function makeScopedAgentService() {
const foreign = makeAgentSession(USER_A);
return {
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
),
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
),
destroySession: vi.fn(),
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
recordMessage: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
}
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
it('requires explicit owner+tenant scope on protected session operations', () => {
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain('listSessions(scope: ActorTenantScope)');
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain(
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
);
expect(source).toContain('scope: ActorTenantScope,');
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
expect(source).not.toContain('scope?: ActorTenantScope');
});
});
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
expect(agentService.listSessions).toHaveBeenCalledWith({
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not reveal another owner/tenant session by guessed id', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not terminate another owner/tenant session by guessed id', async () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
NotFoundException,
);
expect(agentService.destroySession).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
const agentService = makeScopedAgentService();
const controller = new ChatController(agentService as never);
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toMatchObject({ status: 404 });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.prompt).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
function makeGateway(agentService = makeScopedAgentService()) {
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
const commandExecutor = { execute: vi.fn() };
const routingEngine = {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
commandRegistry as never,
commandExecutor as never,
routingEngine as never,
);
return { gateway, agentService };
}
function makeSocket() {
return {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
}
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
});
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.onEvent).not.toHaveBeenCalled();
expect(agentService.addChannel).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
});

View File

@@ -1,4 +1,5 @@
import { Global, Module } from '@nestjs/common';
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
@@ -8,24 +9,66 @@ import { SkillLoaderService } from './skill-loader.service.js';
import { ProvidersController } from './providers.controller.js';
import { SessionsController } from './sessions.controller.js';
import { AgentConfigsController } from './agent-configs.controller.js';
import { InteractionController } from './interaction.controller.js';
import { RoutingController } from './routing/routing.controller.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
const registry = new AgentRuntimeProviderRegistry();
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
return registry;
}
@Global()
@Module({
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
providers: [
ProviderService,
ProviderCredentialsService,
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionRepository,
DurableSessionService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry,
},
RuntimeProviderAuditService,
{
provide: RUNTIME_PROVIDER_AUDIT_SINK,
useExisting: RuntimeProviderAuditService,
},
{
provide: RUNTIME_APPROVAL_VERIFIER,
useExisting: CommandRuntimeApprovalVerifier,
},
RuntimeProviderService,
AgentService,
],
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
controllers: [
ProvidersController,
SessionsController,
AgentConfigsController,
InteractionController,
RoutingController,
],
exports: [
AgentService,
ProviderService,
@@ -33,6 +76,9 @@ import { GCModule } from '../gc/gc.module.js';
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionService,
RuntimeProviderService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
],
})
export class AgentModule {}

View File

@@ -1,4 +1,11 @@
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
import {
ForbiddenException,
Inject,
Injectable,
Logger,
Optional,
type OnModuleDestroy,
} from '@nestjs/common';
import {
createAgentSession,
DefaultResourceLoader,
@@ -8,9 +15,11 @@ import {
type ToolDefinition,
} from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaicstack/brain';
import type { Memory } from '@mosaicstack/memory';
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js';
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
import { EmbeddingService } from '../memory/embedding.service.js';
import { CoordService } from '../coord/coord.service.js';
import { ProviderService } from './provider.service.js';
@@ -28,12 +37,15 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
import { PreferencesService } from '../preferences/preferences.service.js';
import { SessionGCService } from '../gc/session-gc.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
/** A single message from DB conversation history, used for context injection. */
export interface ConversationHistoryMessage {
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: Date;
/** Validated, URI-referenced channel attachments preserved on session resume. */
attachments?: readonly ChannelAttachmentDto[];
}
export interface AgentSessionOptions {
@@ -68,6 +80,8 @@ export interface AgentSessionOptions {
agentConfigId?: string;
/** ID of the user who owns this session. Used for preferences and system override lookups. */
userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/**
* Prior conversation messages to inject as context when resuming a session.
* These messages are formatted and prepended to the system prompt so the
@@ -94,6 +108,8 @@ export interface AgentSession {
allowedTools: string[] | null;
/** User ID that owns this session, used for preference lookups. */
userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/** Agent config ID applied to this session, if any (M5-001). */
agentConfigId?: string;
/** Human-readable agent name applied to this session, if any (M5-001). */
@@ -123,6 +139,9 @@ export class AgentService implements OnModuleDestroy {
@Inject(PreferencesService)
private readonly preferencesService: PreferencesService | null,
@Inject(SessionGCService) private readonly gc: SessionGCService,
@Optional()
@Inject(OPERATOR_MEMORY_PLUGIN)
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
) {}
/**
@@ -134,6 +153,7 @@ export class AgentService implements OnModuleDestroy {
private buildToolsForSandbox(
sandboxDir: string,
sessionUserId: string | undefined,
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
): ToolDefinition[] {
return [
...createBrainTools(this.brain),
@@ -142,6 +162,9 @@ export class AgentService implements OnModuleDestroy {
this.memory,
this.embeddingService.available ? this.embeddingService : null,
sessionUserId,
this.operatorMemory && sessionScope
? { plugin: this.operatorMemory, scope: sessionScope }
: undefined,
),
...createFileTools(sandboxDir),
...createGitTools(sandboxDir),
@@ -174,12 +197,20 @@ export class AgentService implements OnModuleDestroy {
.filter((t) => t.length > 0);
}
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
const scope = this.scopeFromOptions(options);
const existing = this.sessions.get(sessionId);
if (existing) return existing;
if (existing) {
this.assertSessionScope(existing, scope);
return existing;
}
const inflight = this.creating.get(sessionId);
if (inflight) return inflight;
if (inflight) {
const session = await inflight;
this.assertSessionScope(session, scope);
return session;
}
const promise = this.doCreateSession(sessionId, options).finally(() => {
this.creating.delete(sessionId);
@@ -208,6 +239,7 @@ export class AgentService implements OnModuleDestroy {
isAdmin: options.isAdmin,
agentConfigId: options.agentConfigId,
userId: options.userId,
tenantId: options.tenantId,
conversationHistory: options.conversationHistory,
};
this.logger.log(
@@ -247,7 +279,15 @@ export class AgentService implements OnModuleDestroy {
}
// Build per-session tools scoped to the sandbox directory and authenticated user
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
const sessionUserId = mergedOptions?.userId;
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
const sandboxTools = this.buildToolsForSandbox(
sandboxDir,
sessionUserId,
sessionUserId && sessionTenantId
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
: undefined,
);
// Combine static tools with dynamically discovered MCP client tools and skill tools
const mcpTools = this.mcpClientService.getToolDefinitions();
@@ -342,6 +382,7 @@ export class AgentService implements OnModuleDestroy {
sandboxDir,
allowedTools,
userId: mergedOptions?.userId,
tenantId: sessionTenantId,
agentConfigId: mergedOptions?.agentConfigId,
agentName: resolvedAgentName,
metrics: {
@@ -390,7 +431,7 @@ export class AgentService implements OnModuleDestroy {
const formatMessage = (msg: ConversationHistoryMessage): string => {
const roleLabel =
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
return `**${roleLabel}:** ${msg.content}`;
return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`;
};
const formatted = history.map((msg) => formatMessage(msg));
@@ -449,6 +490,21 @@ export class AgentService implements OnModuleDestroy {
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) {
if (!options?.provider && !options?.modelId) {
return this.providerService.getDefaultModel() ?? null;
@@ -473,38 +529,70 @@ export class AgentService implements OnModuleDestroy {
return this.providerService.getDefaultModel() ?? null;
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
const session = this.sessions.get(sessionId);
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
return session;
}
listSessions(): SessionInfoDto[] {
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => ({
id: s.id,
provider: s.provider,
modelId: s.modelId,
...(s.agentName ? { agentName: s.agentName } : {}),
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: now - s.createdAt,
metrics: { ...s.metrics },
}));
return Array.from(this.sessions.values())
.filter((s) => this.sessionMatchesScope(s, scope))
.map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
listAllSessionsForSystem(): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined {
const s = this.sessions.get(sessionId);
if (!s) return undefined;
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
return this.toSessionInfo(s);
}
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
if (!options.userId) {
throw new ForbiddenException('Session owner scope is required');
}
return {
id: s.id,
provider: s.provider,
modelId: s.modelId,
...(s.agentName ? { agentName: s.agentName } : {}),
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: Date.now() - s.createdAt,
metrics: { ...s.metrics },
userId: options.userId,
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
};
}
private tenantIdFor(
userId: string | undefined,
tenantId: string | undefined,
): string | undefined {
return tenantId ?? userId;
}
private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean {
return (
session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId
);
}
private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void {
if (!this.sessionMatchesScope(session, scope)) {
throw new ForbiddenException('Session does not belong to the current owner/tenant scope');
}
}
private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto {
return {
id: session.id,
provider: session.provider,
modelId: session.modelId,
...(session.agentName ? { agentName: session.agentName } : {}),
createdAt: new Date(session.createdAt).toISOString(),
promptCount: session.promptCount,
channels: Array.from(session.channels),
durationMs: now - session.createdAt,
metrics: { ...session.metrics },
};
}
@@ -553,9 +641,10 @@ export class AgentService implements OnModuleDestroy {
* not reconstructed — the model is used on the next createSession call for
* the same conversationId when the session is torn down or a new one is created.
*/
updateSessionModel(sessionId: string, modelId: string): void {
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
const prev = session.modelId;
session.modelId = modelId;
this.recordModelSwitch(sessionId);
@@ -572,48 +661,67 @@ export class AgentService implements OnModuleDestroy {
sessionId: string,
agentConfigId: string,
agentName: string,
scope: ActorTenantScope,
modelId?: string,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
session.agentConfigId = agentConfigId;
session.agentName = agentName;
if (modelId) {
this.updateSessionModel(sessionId, modelId);
this.updateSessionModel(sessionId, modelId, scope);
}
this.logger.log(
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
);
}
addChannel(sessionId: string, channel: string): void {
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.add(channel);
}
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.add(channel);
}
removeChannel(sessionId: string, channel: string): void {
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.delete(channel);
}
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.delete(channel);
}
async prompt(sessionId: string, message: string): 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);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
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)
let effectiveMessage = message;
let effectiveMessage = `${message}${attachmentContext}`;
if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId);
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
await this.systemOverride.renew(sessionId);
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}
}
@@ -629,16 +737,28 @@ export class AgentService implements OnModuleDestroy {
}
}
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
onEvent(
sessionId: string,
listener: (event: AgentSessionEvent) => void,
scope: ActorTenantScope,
): () => void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.listeners.add(listener);
return () => session.listeners.delete(listener);
}
async destroySession(sessionId: string): Promise<void> {
async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
await this.destroySessionForSystem(sessionId);
}
private async destroySessionForSystem(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.logger.log(`Destroying agent session ${sessionId}`);
@@ -667,7 +787,7 @@ export class AgentService implements OnModuleDestroy {
async onModuleDestroy(): Promise<void> {
this.logger.log('Shutting down all agent sessions');
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
const results = await Promise.allSettled(stops);
for (const result of results) {
if (result.status === 'rejected') {

View File

@@ -0,0 +1,10 @@
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
/** Server-side request for a replay-safe provider message. */
export interface ProviderOutboxDto {
sessionId: string;
idempotencyKey: string;
correlationId: string;
content: string;
context: RuntimeProviderRequestContext;
}

View File

@@ -0,0 +1,416 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova',
sessionId: 'tess-pglite-session',
tenantId: 'tenant-pglite',
ownerId: 'tess-owner',
providerId: 'fleet',
runtimeSessionId: 'nova',
};
describe('DurableSessionRepository', () => {
let dataDir: string | undefined;
let handle: DbHandle;
let previousAuthSecret: string | undefined;
beforeAll(async (): Promise<void> => {
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key';
dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
await seedOwner(handle);
}, 30_000);
beforeEach(async (): Promise<void> => {
await handle.db.execute(sql`DELETE FROM interaction_handoffs`);
await handle.db.execute(sql`DELETE FROM interaction_checkpoints`);
await handle.db.execute(sql`DELETE FROM interaction_inbox`);
await handle.db.execute(sql`DELETE FROM interaction_outbox`);
await handle.db.execute(sql`DELETE FROM interaction_sessions`);
});
afterAll(async (): Promise<void> => {
await handle.close();
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
});
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await beforeRestart.create(IDENTITY);
await beforeRestart.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-before-kill',
correlationId: 'correlation-before-kill',
content: 'resume after a kill',
});
await beforeRestart.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-before-kill',
correlationId: 'correlation-before-kill',
channelId: 'cli',
kind: 'provider.send',
content: 'one response only',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-before-kill',
cursor: 'cursor-before-kill',
summary: 'restart-safe state',
compactionEpoch: 1,
});
await beforeRestart.handoff({
sessionId: IDENTITY.sessionId,
handoffId: 'handoff-before-kill',
destination: 'mos',
correlationId: 'correlation-before-kill',
checkpointId: 'checkpoint-before-kill',
status: 'pending',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-after-handoff',
cursor: 'cursor-after-handoff',
summary: 'newer state cannot strand the portable handoff',
compactionEpoch: 2,
});
await handle.close();
handle = createPgliteDb(dataDir!);
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const recovered = await afterRestart.recover(IDENTITY.sessionId);
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
const handled: string[] = [];
const effects: string[] = [];
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
handled.push(entry.idempotencyKey);
});
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
effects.push(entry.idempotencyKey);
});
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
handled.push(entry.idempotencyKey);
});
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
effects.push(entry.idempotencyKey);
});
expect(recovered.identity).toEqual(IDENTITY);
expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' });
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]);
expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' });
expect(handled).toEqual(['inbox-before-kill']);
expect(effects).toEqual(['outbox-before-kill']);
}, 30_000);
it('redacts sensitive durable payloads before persistence', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'redacted-inbox',
correlationId: 'correlation-redaction',
content: 'api_key=super-secret-canary',
});
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'redacted-outbox',
correlationId: 'correlation-redaction',
channelId: 'cli',
kind: 'provider.send',
content: 'email operator@example.test api_key=super-secret-canary',
});
await coordinator.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'redacted-checkpoint',
cursor: 'bearer super-secret-canary',
summary: 'email operator@example.test',
compactionEpoch: 0,
});
const snapshot = await coordinator.snapshot(IDENTITY.sessionId);
const [persisted] = await handle.db
.select({ content: interactionInbox.content })
.from(interactionInbox)
.where(eq(interactionInbox.idempotencyKey, 'redacted-inbox'));
expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary');
expect(JSON.stringify(snapshot)).not.toContain('operator@example.test');
expect(persisted?.content).not.toContain('super-secret-canary');
expect(persisted?.content).not.toContain('[REDACTED]');
}, 30_000);
it('fails closed when the configured idempotency secret is unavailable', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const secret = process.env['BETTER_AUTH_SECRET'];
delete process.env['BETTER_AUTH_SECRET'];
try {
await expect(
coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'requires-idempotency-secret',
correlationId: 'correlation-secret',
content: 'sensitive payload',
}),
).rejects.toThrow(/required for durable idempotency digests/);
} finally {
if (secret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = secret;
}
}, 30_000);
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const input = {
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-secret-conflict',
cursor: 'api_key=secret-one',
summary: 'bearer secret-one',
compactionEpoch: 1,
};
await coordinator.checkpoint(input);
await expect(
coordinator.checkpoint({
...input,
cursor: 'api_key=secret-two',
summary: 'bearer secret-two',
}),
).rejects.toThrow(/checkpoint identity conflict/);
const [persisted] = await handle.db
.select({
digest: interactionCheckpoints.contentDigest,
cursor: interactionCheckpoints.cursor,
})
.from(interactionCheckpoints)
.where(eq(interactionCheckpoints.checkpointId, input.checkpointId));
expect(persisted?.cursor).not.toContain('secret-one');
expect(persisted?.digest).not.toBe(
createHash('sha256')
.update(JSON.stringify([input.cursor, input.summary]))
.digest('hex'),
);
await coordinator.checkpoint({
...input,
checkpointId: 'checkpoint-delimiter-conflict',
cursor: 'a\u0000b',
summary: 'c',
});
await expect(
coordinator.checkpoint({
...input,
checkpointId: 'checkpoint-delimiter-conflict',
cursor: 'a',
summary: 'b\u0000c',
}),
).rejects.toThrow(/checkpoint identity conflict/);
}, 30_000);
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const inbox = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-secret-conflict',
correlationId: 'correlation-inbox-secret',
content: 'api_key=secret-one',
};
const outbox = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-secret-conflict',
correlationId: 'correlation-outbox-secret',
channelId: 'cli',
kind: 'provider.send',
content: 'api_key=secret-one',
};
await coordinator.receive(inbox);
await coordinator.enqueueOutbox(outbox);
await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow(
/idempotency conflict/,
);
await expect(
coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }),
).rejects.toThrow(/idempotency conflict/);
}, 30_000);
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-conflict',
correlationId: 'correlation-inbox',
content: 'original inbox',
});
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'cli',
kind: 'provider.send',
content: 'original outbox',
});
await expect(
coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-conflict',
correlationId: 'forged-correlation',
content: 'original inbox',
}),
).rejects.toThrow(/idempotency conflict/);
await expect(
coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'forged-channel',
kind: 'provider.send',
content: 'original outbox',
}),
).rejects.toThrow(/idempotency conflict/);
}, 30_000);
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'live-effect',
correlationId: 'correlation-live',
content: 'must not duplicate',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-live',
},
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(input);
expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({
status: 'processing',
});
await service.dispatchProviderOutbox(IDENTITY.sessionId, input);
await expect(
service.recoverProviderSession(IDENTITY.sessionId, {
...input,
context: {
...input.context,
actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' },
},
}),
).rejects.toThrow(/scope or correlation mismatch/);
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }],
});
}, 30_000);
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'mismatch-effect',
correlationId: 'correlation-expected',
content: 'must remain pending',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-expected',
},
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(input);
await expect(
service.dispatchProviderOutbox(IDENTITY.sessionId, {
...input,
correlationId: 'correlation-forged',
context: { ...input.context, correlationId: 'correlation-forged' },
}),
).rejects.toThrow(/scope or correlation mismatch/);
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }],
});
}, 30_000);
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const first = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'scoped-effect-one',
correlationId: 'correlation-one',
content: 'first result',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-one',
},
};
const second = {
...first,
idempotencyKey: 'scoped-effect-two',
correlationId: 'correlation-two',
content: 'second result',
context: { ...first.context, correlationId: 'correlation-two' },
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(first);
await service.queueProviderSend(second);
await service.dispatchProviderOutbox(IDENTITY.sessionId, first);
expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1);
expect(runtimeProviders.sendMessage).toHaveBeenCalledWith(
IDENTITY.providerId,
IDENTITY.runtimeSessionId,
{ content: 'first result', idempotencyKey: 'scoped-effect-one' },
first.context,
);
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [
{ idempotencyKey: 'scoped-effect-one', status: 'delivered' },
{ idempotencyKey: 'scoped-effect-two', status: 'pending' },
],
});
}, 30_000);
});
async function seedOwner(handle: DbHandle): Promise<void> {
await handle.db.execute(sql`
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
VALUES ('tess-owner', 'Tess Owner', 'tess-owner@example.test', false, now(), now())
`);
}

View File

@@ -0,0 +1,529 @@
import { createHash, createHmac } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
desc,
eq,
interactionCheckpoints,
interactionHandoffs,
interactionInbox,
interactionOutbox,
interactionSessions,
type Db,
} from '@mosaicstack/db';
import { seal, unseal } from '@mosaicstack/auth';
import { redactSensitiveContent } from '@mosaicstack/log';
import type {
DurableCheckpoint,
DurableCheckpointInput,
DurableEnqueueResult,
DurableHandoff,
DurableHandoffInput,
DurableInboxEntry,
DurableInboxInput,
DurableInboxStatus,
DurableOutboxEntry,
DurableOutboxInput,
DurableOutboxStatus,
DurableSessionIdentity,
DurableSessionSnapshot,
DurableSessionStore,
} from '@mosaicstack/agent';
import { DB } from '../database/database.module.js';
@Injectable()
export class DurableSessionRepository implements DurableSessionStore {
constructor(@Inject(DB) private readonly db: Db) {}
async create(identity: DurableSessionIdentity): Promise<void> {
await this.db
.insert(interactionSessions)
.values({
id: identity.sessionId,
agentName: identity.agentName,
tenantId: identity.tenantId,
ownerId: identity.ownerId,
providerId: identity.providerId,
runtimeSessionId: identity.runtimeSessionId,
})
.onConflictDoNothing();
const existing = await this.session(identity.sessionId);
if (!existing || !sameEnrollmentScope(existing, identity)) {
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
}
// A recovered/re-enrolled runtime can receive a new provider session ID;
// the conversation handle and owner scope remain immutable.
if (
existing.providerId !== identity.providerId ||
existing.runtimeSessionId !== identity.runtimeSessionId
) {
await this.db
.update(interactionSessions)
.set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId })
.where(eq(interactionSessions.id, identity.sessionId));
}
}
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
const identity = await this.session(sessionId);
if (!identity) return null;
const [inbox, outbox, checkpoints, handoffs] = await Promise.all([
this.db
.select()
.from(interactionInbox)
.where(eq(interactionInbox.sessionId, sessionId))
.orderBy(asc(interactionInbox.createdAt)),
this.db
.select()
.from(interactionOutbox)
.where(eq(interactionOutbox.sessionId, sessionId))
.orderBy(asc(interactionOutbox.createdAt)),
this.db
.select()
.from(interactionCheckpoints)
.where(eq(interactionCheckpoints.sessionId, sessionId))
.orderBy(
desc(interactionCheckpoints.compactionEpoch),
desc(interactionCheckpoints.createdAt),
)
.limit(1),
this.db
.select()
.from(interactionHandoffs)
.where(eq(interactionHandoffs.sessionId, sessionId))
.orderBy(asc(interactionHandoffs.createdAt)),
]);
const checkpoint = checkpoints[0];
return {
identity,
inbox: inbox.map(toInbox),
outbox: outbox.map(toOutbox),
...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}),
handoffs: handoffs.map(toHandoff),
};
}
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
const digest = contentDigest(input.content);
const record: DurableInboxInput = {
...input,
content: redactSensitiveContent(input.content).content,
};
const inserted = await this.db
.insert(interactionInbox)
.values({
...record,
content: seal(record.content),
contentDigest: digest,
status: 'pending',
})
.onConflictDoNothing()
.returning({ status: interactionInbox.status });
if (inserted[0]) return { accepted: true, status: inserted[0].status };
const existing = await this.db
.select()
.from(interactionInbox)
.where(
and(
eq(interactionInbox.sessionId, input.sessionId),
eq(interactionInbox.idempotencyKey, input.idempotencyKey),
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
const entry = toInbox(existing[0]);
if (
!sameInbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const candidate = await this.db
.select()
.from(interactionInbox)
.where(
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')),
)
.orderBy(asc(interactionInbox.createdAt))
.limit(1);
const entry = candidate[0];
if (!entry) return null;
const claimed = await this.db
.update(interactionInbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending')))
.returning();
if (claimed[0]) return toInbox(claimed[0]);
}
return null;
}
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionInbox)
.set({ status: 'processed', updatedAt: new Date() })
.where(
and(
eq(interactionInbox.sessionId, sessionId),
eq(interactionInbox.idempotencyKey, idempotencyKey),
eq(interactionInbox.status, 'processing'),
),
);
}
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionInbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(
eq(interactionInbox.sessionId, sessionId),
eq(interactionInbox.idempotencyKey, idempotencyKey),
eq(interactionInbox.status, 'processing'),
),
);
}
async enqueueOutbox(
input: DurableOutboxInput,
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
const digest = contentDigest(input.content);
const record: DurableOutboxInput = {
...input,
content: redactSensitiveContent(input.content).content,
};
const inserted = await this.db
.insert(interactionOutbox)
.values({
...record,
content: seal(record.content),
contentDigest: digest,
status: 'pending',
})
.onConflictDoNothing()
.returning({ status: interactionOutbox.status });
if (inserted[0]) return { accepted: true, status: inserted[0].status };
const existing = await this.db
.select()
.from(interactionOutbox)
.where(
and(
eq(interactionOutbox.sessionId, input.sessionId),
eq(interactionOutbox.idempotencyKey, input.idempotencyKey),
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
const entry = toOutbox(existing[0]);
if (
!sameOutbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const candidate = await this.db
.select()
.from(interactionOutbox)
.where(
and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')),
)
.orderBy(asc(interactionOutbox.createdAt))
.limit(1);
const entry = candidate[0];
if (!entry) return null;
const claimed = await this.db
.update(interactionOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending')))
.returning();
if (claimed[0]) return toOutbox(claimed[0]);
}
return null;
}
async claimOutboxByKey(
sessionId: string,
idempotencyKey: string,
): Promise<DurableOutboxEntry | null> {
const claimed = await this.db
.update(interactionOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'pending'),
),
)
.returning();
return claimed[0] ? toOutbox(claimed[0]) : null;
}
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionOutbox)
.set({ status: 'delivered', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'processing'),
),
);
}
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionOutbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'processing'),
),
);
}
async checkpoint(input: DurableCheckpointInput): Promise<void> {
// Compute identity before redaction. The persisted digest is keyed so a database
// reader cannot use it as an offline oracle for sensitive cursor/summary values.
const digest = contentDigest(JSON.stringify([input.cursor, input.summary]));
const checkpoint: DurableCheckpointInput = {
...input,
cursor: redactSensitiveContent(input.cursor).content,
summary: redactSensitiveContent(input.summary).content,
};
const inserted = await this.db
.insert(interactionCheckpoints)
.values({
...checkpoint,
contentDigest: digest,
cursor: seal(checkpoint.cursor),
summary: seal(checkpoint.summary),
})
.onConflictDoNothing()
.returning({ checkpointId: interactionCheckpoints.checkpointId });
if (inserted[0]) return;
const existing = await this.db
.select()
.from(interactionCheckpoints)
.where(
and(
eq(interactionCheckpoints.sessionId, input.sessionId),
eq(interactionCheckpoints.checkpointId, input.checkpointId),
),
)
.limit(1);
if (
!existing[0] ||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
) {
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
}
}
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
const checkpoints = await this.db
.select()
.from(interactionCheckpoints)
.where(
and(
eq(interactionCheckpoints.sessionId, sessionId),
eq(interactionCheckpoints.checkpointId, checkpointId),
),
)
.limit(1);
const checkpoint = checkpoints[0];
return checkpoint ? toCheckpoint(checkpoint) : null;
}
async handoff(input: DurableHandoffInput): Promise<void> {
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
if (!checkpoint) {
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
}
const inserted = await this.db
.insert(interactionHandoffs)
.values({ ...input })
.onConflictDoNothing()
.returning({ handoffId: interactionHandoffs.handoffId });
if (inserted[0]) return;
const existing = await this.findHandoff(input.handoffId);
if (!existing || !sameHandoff(existing, input)) {
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
}
}
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
const handoffs = await this.db
.select()
.from(interactionHandoffs)
.where(eq(interactionHandoffs.handoffId, handoffId))
.limit(1);
const handoff = handoffs[0];
return handoff ? toHandoff(handoff) : null;
}
async requeueInFlight(sessionId: string): Promise<void> {
// Inbox handlers are process-local work. A provider outbox claim may have
// reached an external target before a crash, so it is deliberately not
// replayed by generic recovery.
await this.db
.update(interactionInbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')),
);
}
private async session(sessionId: string): Promise<DurableSessionIdentity | null> {
const sessions = await this.db
.select()
.from(interactionSessions)
.where(eq(interactionSessions.id, sessionId))
.limit(1);
const session = sessions[0];
return session
? {
agentName: session.agentName,
sessionId: session.id,
tenantId: session.tenantId,
ownerId: session.ownerId,
providerId: session.providerId,
runtimeSessionId: session.runtimeSessionId,
}
: null;
}
}
function contentDigest(content: string): string {
const secret = process.env['BETTER_AUTH_SECRET'];
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests');
}
return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`;
}
function matchesContentDigest(stored: string, content: string): boolean {
return (
stored === contentDigest(content) ||
stored === createHash('sha256').update(content).digest('hex')
);
}
function matchesCheckpointDigest(stored: string, digest: string): boolean {
// Legacy rows predate any pre-redaction identity and cannot safely prove equality.
// Reject rather than let redaction collapse distinct sensitive checkpoint payloads.
return stored === digest;
}
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
return (
left.agentName === right.agentName &&
left.sessionId === right.sessionId &&
left.tenantId === right.tenantId &&
left.ownerId === right.ownerId
);
}
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.content === right.content
);
}
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.channelId === right.channelId &&
left.kind === right.kind &&
left.content === right.content
);
}
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
return (
left.sessionId === right.sessionId &&
left.checkpointId === right.checkpointId &&
left.compactionEpoch === right.compactionEpoch
);
}
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
return (
left.sessionId === right.sessionId &&
left.handoffId === right.handoffId &&
left.destination === right.destination &&
left.correlationId === right.correlationId &&
left.checkpointId === right.checkpointId &&
left.status === right.status
);
}
function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry {
return {
sessionId: row.sessionId,
idempotencyKey: row.idempotencyKey,
correlationId: row.correlationId,
content: unseal(row.content),
status: row.status,
};
}
function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry {
return {
sessionId: row.sessionId,
idempotencyKey: row.idempotencyKey,
correlationId: row.correlationId,
channelId: row.channelId,
kind: row.kind,
content: unseal(row.content),
status: row.status,
};
}
function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint {
return {
sessionId: row.sessionId,
checkpointId: row.checkpointId,
cursor: unseal(row.cursor),
summary: unseal(row.summary),
compactionEpoch: row.compactionEpoch,
};
}
function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff {
return {
sessionId: row.sessionId,
handoffId: row.handoffId,
destination: row.destination,
correlationId: row.correlationId,
checkpointId: row.checkpointId,
status: row.status,
};
}

View File

@@ -0,0 +1,123 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import type { ProviderOutboxDto } from './durable-session.dto.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
* uses composition: raw state methods cannot be injected into channel, CLI, or
* MCP adapters without a server-derived actor/tenant/correlation context.
*/
@Injectable()
export class DurableSessionService {
private readonly coordinator: DurableSessionCoordinator;
constructor(
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
) {
this.coordinator = new DurableSessionCoordinator(repository);
}
/** Enroll a verified runtime session under the stable cross-surface conversation handle. */
async enroll(
identity: DurableSessionIdentity,
context: RuntimeProviderRequestContext,
): Promise<void> {
if (
identity.ownerId !== context.actorScope.userId ||
identity.tenantId !== context.actorScope.tenantId
) {
throw new ForbiddenException('Durable session enrollment scope mismatch');
}
await this.coordinator.create(identity);
}
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(input.sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.enqueueOutbox({
sessionId: input.sessionId,
idempotencyKey: input.idempotencyKey,
correlationId: input.correlationId,
channelId: input.context.channelId,
kind: 'provider.send',
content: input.content,
});
}
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
if (sessionId !== input.sessionId) {
throw new ForbiddenException('Durable outbox session mismatch');
}
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
const pendingEntry = snapshot.outbox.find(
(entry): boolean => entry.idempotencyKey === input.idempotencyKey,
);
if (!pendingEntry) return;
// Validate immutable routing before claiming. A caller with a mismatched
// correlation/channel must not strand a pending external side effect.
this.assertOutboxScope(pendingEntry, input);
await this.coordinator.dispatchOutboxEntry(
sessionId,
input.idempotencyKey,
async (entry): Promise<void> => {
this.assertOutboxScope(entry, input);
await this.runtimeProviders.sendMessage(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
{ content: entry.content, idempotencyKey: entry.idempotencyKey },
input.context,
);
},
);
}
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
sessionId,
content: '',
idempotencyKey: 'read-only',
correlationId: context.correlationId,
context,
});
return snapshot;
}
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.recover(sessionId);
}
private assertOutboxScope(
entry: { kind: string; correlationId: string; channelId: string },
input: ProviderOutboxDto,
): void {
if (
entry.kind !== 'provider.send' ||
entry.correlationId !== input.correlationId ||
entry.channelId !== input.context.channelId
) {
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
}
}
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
if (
input.context.actorScope.userId !== ownerId ||
input.context.actorScope.tenantId !== tenantId ||
input.context.correlationId !== input.correlationId
) {
throw new ForbiddenException('Durable session scope or correlation mismatch');
}
}
}

View File

@@ -0,0 +1,176 @@
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 { HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentModule } from './agent.module.js';
import { AUTH } from '../auth/auth.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { BRAIN } from '../brain/brain.tokens.js';
import { DB } from '../database/database.module.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
} from './runtime-provider-registry.service.js';
import { DurableSessionService } from './durable-session.service.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
import { RoutingService } from './routing.service.js';
import { RoutingEngineService } from './routing/routing-engine.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' };
@Module({})
class EmptyAgentDependencyModule {}
@Global()
@Module({
providers: [
{
provide: AUTH,
useValue: {
api: {
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
headers.get('cookie') === 'session=trusted'
? { user: authenticatedUser, session: { id: 'session-1' } }
: null,
),
},
},
},
AuthGuard,
{ provide: BRAIN, useValue: {} },
{ provide: DB, useValue: {} },
],
exports: [AUTH, AuthGuard, BRAIN, DB],
})
class AuthenticatedRequestModule {}
/**
* This is deliberately an HTTP test rather than a controller unit test: it
* exercises AgentModule's actual provider factory, Nest DI, and AuthGuard.
*/
describe('Hermes runtime provider reachability', (): void => {
let app: NestFastifyApplication | undefined;
beforeAll(async (): Promise<void> => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const moduleRef = await Test.createTestingModule({
imports: [AuthenticatedRequestModule, AgentModule],
})
.overrideModule(CoordModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(McpClientModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(SkillsModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(GCModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(LogModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(CommandsModule)
.useModule(EmptyAgentDependencyModule)
.overrideProvider(RuntimeProviderAuditService)
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK)
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
.useValue({ consume: vi.fn().mockResolvedValue(false) })
.overrideProvider(DurableSessionService)
.useValue({})
.overrideProvider(DurableSessionRepository)
.useValue({})
.overrideProvider(AgentService)
.useValue({})
.overrideProvider(ProviderService)
.useValue({})
.overrideProvider(ProviderCredentialsService)
.useValue({})
.overrideProvider(RoutingService)
.useValue({})
.overrideProvider(RoutingEngineService)
.useValue({})
.overrideProvider(SkillLoaderService)
.useValue({})
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async (): Promise<void> => {
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> => {
if (!app) throw new Error('Nest application did not initialize');
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider);
const denied = await app.inject({
method: 'GET',
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
headers: { 'x-correlation-id': 'correlation-1' },
});
expect(denied.statusCode).toBe(401);
const response = await app.inject({
method: 'GET',
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
const scope = {
actorId: 'owner-1',
tenantId: 'tenant-1',
channelId: 'cli',
correlationId: 'correlation-1',
};
describe('GatewayHermesRuntimeTransport', () => {
it('preserves a configured path prefix and authenticates the concrete runtime request', async () => {
const fetchFn = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 }));
const transport = new GatewayHermesRuntimeTransport(
'https://runtime.example.test/hermes',
'test-service-token',
fetchFn,
);
await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']);
expect(fetchFn).toHaveBeenCalledWith(
new URL('https://runtime.example.test/hermes/capabilities'),
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer test-service-token',
'x-mosaic-channel-id': 'cli',
}),
}),
);
});
it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => {
const fetchFn = vi.fn();
const transport = new GatewayHermesRuntimeTransport(
'http://runtime.example.test/hermes',
'test-service-token',
fetchFn,
);
await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS');
expect(fetchFn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,121 @@
import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent';
import type {
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeMessage,
RuntimeScope,
RuntimeStreamEvent,
} from '@mosaicstack/types';
/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */
export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport {
constructor(
private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(),
private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(),
private readonly fetchFn: typeof fetch = fetch,
) {}
async capabilities(scope: RuntimeScope): Promise<string[]> {
return this.request<string[]>('/capabilities', scope);
}
async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> {
return this.request<{ status: string; detail?: string }>('/health', scope);
}
async sessions(scope: RuntimeScope): Promise<HermesLegacySession[]> {
return this.request<HermesLegacySession[]>('/sessions', scope);
}
async *stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
const params = new URLSearchParams(cursor ? { cursor } : {});
const events = await this.request<RuntimeStreamEvent[]>(
`/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`,
scope,
);
yield* events;
}
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, {
method: 'POST',
body: message,
});
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
return this.request<RuntimeAttachHandle>(
`/sessions/${encodeURIComponent(sessionId)}/attach`,
scope,
{
method: 'POST',
body: { mode },
},
);
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, {
method: 'DELETE',
});
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, {
method: 'POST',
body: { approvalRef },
});
}
private async request<T>(
path: string,
scope: RuntimeScope,
init: { method?: string; body?: unknown } = {},
): Promise<T> {
if (!this.baseUrl || !this.serviceToken) {
throw new Error(
'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport',
);
}
const endpoint = new URL(this.baseUrl);
if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) {
throw new Error('Hermes runtime transport requires HTTPS outside loopback');
}
const response = await this.fetchFn(
new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`),
{
method: init.method ?? 'GET',
headers: {
accept: 'application/json',
authorization: `Bearer ${this.serviceToken}`,
'x-mosaic-actor-id': scope.actorId,
'x-mosaic-tenant-id': scope.tenantId,
'x-mosaic-channel-id': scope.channelId,
'x-correlation-id': scope.correlationId,
...(init.body ? { 'content-type': 'application/json' } : {}),
},
...(init.body ? { body: JSON.stringify(init.body) } : {}),
},
);
if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`);
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
}
function isLoopbackHttp(endpoint: URL): boolean {
return (
endpoint.protocol === 'http:' &&
(endpoint.hostname === 'localhost' ||
endpoint.hostname === '127.0.0.1' ||
endpoint.hostname === '::1')
);
}

View File

@@ -0,0 +1,263 @@
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
import { firstValueFrom } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
RuntimeApprovalDeniedError,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import { InteractionController } from './interaction.controller.js';
describe('InteractionController', (): void => {
afterEach(() => vi.restoreAllMocks());
it('maps a denied runtime approval to Fastify HTTP 403', () => {
const send = vi.fn();
const status = vi.fn().mockReturnValue({ send });
const response = { status };
const host = { switchToHttp: () => ({ getResponse: () => response }) };
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
expect(status).toHaveBeenCalledWith(403);
expect(send).toHaveBeenCalledWith({
statusCode: 403,
message: 'Runtime termination approval denied',
});
});
it('honors a differently named configured instance without a code change', async () => {
const prior = process.env['MOSAIC_AGENT_NAME'];
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
const controller = new InteractionController(runtime as never, {} as never);
await expect(
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
).resolves.toEqual([]);
await expect(
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
).rejects.toThrow('Interaction agent is not configured');
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prior;
});
it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const registry = createGatewayRuntimeProviderRegistry();
const runtime = new RuntimeProviderService(
registry,
{ record: vi.fn().mockResolvedValue(undefined) },
{ consume: vi.fn().mockResolvedValue(false) },
);
const controller = new InteractionController(runtime, {} as never);
await expect(
controller.transitionalCapabilities(
'Nova',
'runtime.hermes',
{ id: 'owner', tenantId: 'team' },
'corr-1',
),
).resolves.toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
it('rejects a request without the non-simple correlation header', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
'X-Correlation-Id is required',
);
});
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = {
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
};
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.enroll(
'Nova',
'conversation-1',
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
{ id: 'owner', tenantId: 'team' },
'corr-1',
),
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
expect(durable.enroll).toHaveBeenCalledWith(
{
agentName: 'Nova',
sessionId: 'conversation-1',
tenantId: 'team',
ownerId: 'owner',
providerId: 'fleet',
runtimeSessionId: 'runtime-1',
},
expect.objectContaining({ correlationId: 'corr-1' }),
);
});
it('rejects an invalid attach mode before invoking a provider', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
await expect(
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
).rejects.toThrow('Interaction attach mode is invalid');
});
it('resumes a durable session by attaching and streaming its runtime events', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtimeEvent = {
type: 'message.delta' as const,
sessionId: 'runtime-1',
cursor: 'cursor-1',
occurredAt: '2026-07-13T00:00:00.000Z',
content: 'resumed',
};
const runtime = {
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
streamSession: vi.fn(async function* () {
yield runtimeEvent;
}),
};
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
await expect(
firstValueFrom(
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
),
).resolves.toEqual({ data: runtimeEvent });
expect(runtime.attach).toHaveBeenCalledWith(
'fleet',
'runtime-1',
'read',
expect.objectContaining({ correlationId: 'corr-1' }),
);
expect(runtime.streamSession).toHaveBeenCalledWith(
'fleet',
'runtime-1',
undefined,
expect.objectContaining({ correlationId: 'corr-1' }),
);
});
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
resolveSnapshot = resolve;
});
const runtime = { streamSession: vi.fn() };
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
const controller = new InteractionController(runtime as never, durable as never);
const subscription = controller
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
.subscribe();
subscription.unsubscribe();
resolveSnapshot({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(runtime.streamSession).not.toHaveBeenCalled();
});
it.each([
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
[
'session-agent mismatch',
{
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
},
],
])('denies a CLI stop for %s', async (_reason, durable) => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
),
).rejects.toBeDefined();
expect(runtime.terminate).not.toHaveBeenCalled();
});
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = {
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
};
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
),
).rejects.toThrow('Runtime termination approval denied');
});
it('uses the durable session identity and runtime registry for an approved stop', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
);
expect(runtime.terminate).toHaveBeenCalledWith(
'fleet',
'runtime-1',
'approval-1',
expect.objectContaining({
correlationId: 'corr-1',
actorScope: { userId: 'owner', tenantId: 'owner' },
}),
);
});
});

View File

@@ -0,0 +1,293 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Sse,
Inject,
Param,
Post,
Query,
UseGuards,
UseFilters,
} from '@nestjs/common';
import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types';
import { Observable } from 'rxjs';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { DurableSessionService } from './durable-session.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Authenticated HTTP boundary for operator interaction clients. Identity is
* selected from deployment configuration, never a client-side command name.
*/
@Controller('api/interaction/:agentName')
@UseGuards(AuthGuard)
@UseFilters(RuntimeApprovalDeniedFilter)
export class InteractionController {
constructor(
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
) {}
@Get('sessions')
async sessions(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.listSessions(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
@Get('transitional-capabilities')
async transitionalCapabilities(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.transitionalCapabilityMatrix(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
@Get('tree')
async tree(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.getSessionTree(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
/**
* Bind an existing, authorized runtime session to the stable conversation ID.
* This is the lifecycle boundary where both runtime identifiers are known.
*/
@Post('sessions/:sessionId/enroll')
async enroll(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { providerId?: string; runtimeSessionId?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const providerId = this.requiredProvider(body.providerId ?? '');
const runtimeSessionId = body.runtimeSessionId?.trim();
if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required');
const context = this.context(user, correlationId);
const sessions = await this.runtime.listSessions(providerId, context);
if (!sessions.some((session): boolean => session.id === runtimeSessionId)) {
throw new ForbiddenException('Runtime session is not visible to this actor');
}
await this.durable.enroll(
{
agentName,
sessionId,
tenantId: context.actorScope.tenantId,
ownerId: context.actorScope.userId,
providerId,
runtimeSessionId,
},
context,
);
return { status: 'enrolled', sessionId };
}
@Post('sessions/:sessionId/attach')
async attach(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { mode?: RuntimeAttachMode } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
const mode = body.mode ?? 'read';
if (mode !== 'read' && mode !== 'control') {
throw new ForbiddenException('Interaction attach mode is invalid');
}
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
return this.runtime.attach(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
mode,
context,
);
}
@Sse('sessions/:sessionId/stream')
stream(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Query('cursor') cursor: string | undefined,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Observable<{ data: RuntimeStreamEvent }> {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
return new Observable((subscriber) => {
let iterator: AsyncIterator<RuntimeStreamEvent> | undefined;
let cancelled = false;
void (async (): Promise<void> => {
try {
const snapshot = await this.durable.getSnapshot(sessionId, context);
if (cancelled || subscriber.closed) return;
this.assertSessionAgent(snapshot.identity.agentName, agentName);
iterator = this.runtime
.streamSession(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
cursor?.trim() || undefined,
context,
)
[Symbol.asyncIterator]();
if (cancelled || subscriber.closed) {
await iterator.return?.();
return;
}
while (!cancelled && !subscriber.closed) {
const next = await iterator.next();
if (next.done || cancelled || subscriber.closed) break;
subscriber.next({ data: next.value });
}
if (!subscriber.closed) subscriber.complete();
} catch (error: unknown) {
if (!subscriber.closed) subscriber.error(error);
}
})();
return (): void => {
cancelled = true;
void iterator?.return?.();
};
});
}
@Post('sessions/:sessionId/send')
async send(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { content?: string; idempotencyKey?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
throw new ForbiddenException('Content and idempotency key are required');
}
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
const input = {
sessionId,
content: body.content,
idempotencyKey: body.idempotencyKey,
correlationId: context.correlationId,
context,
};
await this.durable.queueProviderSend(input);
await this.durable.dispatchProviderOutbox(sessionId, input);
return { status: 'queued', sessionId };
}
@Post('sessions/:sessionId/stop')
async stop(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { approvalRef?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
if (!body.approvalRef?.trim())
throw new ForbiddenException('Exact-action approval is required');
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
await this.runtime.terminate(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
body.approvalRef,
context,
);
return { status: 'stopped', sessionId };
}
@Post('sessions/:sessionId/recover')
async recover(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
await this.durable.recoverProviderSession(sessionId, {
sessionId,
content: '',
idempotencyKey: `recovery:${context.correlationId}`,
correlationId: context.correlationId,
context,
});
return { status: 'recovered', sessionId };
}
private context(
user: AuthenticatedUserLike,
correlationId?: string,
): RuntimeProviderRequestContext {
const requestCorrelationId = correlationId?.trim();
// This non-simple request header is mandatory for mutations. Browser
// cross-origin requests cannot set it without a CORS preflight, and the
// gateway's allowlist rejects untrusted origins before the handler runs.
if (!requestCorrelationId) {
throw new ForbiddenException('X-Correlation-Id is required');
}
return {
actorScope: scopeFromUser(user),
channelId: 'cli',
correlationId: requestCorrelationId,
};
}
private assertConfiguredAgent(agentName: string): void {
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!configured || configured !== agentName) {
throw new ForbiddenException('Interaction agent is not configured for this request');
}
}
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
if (sessionAgentName !== agentName)
throw new ForbiddenException('Interaction session identity mismatch');
}
private requiredProvider(providerId: string): string {
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
return providerId;
}
}

View File

@@ -107,8 +107,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
*/
private startHealthCheckScheduler(): void {
const intervalSecs =
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS;
const intervalSecs = this.effectiveHealthCheckIntervalSecs();
const intervalMs = intervalSecs * 1000;
// Run an initial check immediately (non-blocking)
@@ -176,6 +175,28 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
});
}
/**
* Returns the effective provider operational policy without credentials,
* endpoints, request content, or provider error details.
*/
getEffectivePolicyStatus(): {
healthCheckIntervalSecs: number;
configuredProviders: string[];
availableModelCount: number;
} {
return {
healthCheckIntervalSecs: this.effectiveHealthCheckIntervalSecs(),
configuredProviders: this.adapters.map((adapter) => adapter.name),
availableModelCount: this.registry?.getAvailable().length ?? 0,
};
}
private effectiveHealthCheckIntervalSecs(): number {
return (
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS
);
}
// ---------------------------------------------------------------------------
// Adapter-pattern API
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { ProvidersController } from './providers.controller.js';
describe('ProvidersController operational status', (): void => {
it('reports provider latency and effective policy without exposing provider error details', (): void => {
const providerService = {
getProvidersHealth: vi.fn(() => [
{
name: 'fleet',
status: 'down',
latencyMs: 42,
lastChecked: '2026-07-12T00:00:00.000Z',
modelCount: 0,
error: 'credential-canary=secret-value',
},
]),
getEffectivePolicyStatus: vi.fn(() => ({
healthCheckIntervalSecs: 60,
configuredProviders: ['fleet'],
availableModelCount: 0,
})),
};
const controller = new ProvidersController(providerService as never, {} as never, {} as never);
const status = controller.status();
expect(status).toEqual({
providers: [
{
name: 'fleet',
status: 'down',
latencyMs: 42,
lastChecked: '2026-07-12T00:00:00.000Z',
modelCount: 0,
errorCode: 'provider_unavailable',
},
],
effectivePolicy: {
healthCheckIntervalSecs: 60,
configuredProviders: ['fleet'],
availableModelCount: 0,
},
});
expect(JSON.stringify(status)).not.toContain('secret-value');
});
});

View File

@@ -33,7 +33,20 @@ export class ProvidersController {
@Get('health')
health() {
return { providers: this.providerService.getProvidersHealth() };
return { providers: this.safeProviderHealth() };
}
/**
* Safe operational status for troubleshooting and readiness checks. Provider
* errors are reduced to a stable code so credentials and remote responses
* cannot leak through this endpoint.
*/
@Get('status')
status() {
return {
providers: this.safeProviderHealth(),
effectivePolicy: this.providerService.getEffectivePolicyStatus(),
};
}
@Post('test')
@@ -51,6 +64,13 @@ export class ProvidersController {
return this.routingService.rank(criteria);
}
private safeProviderHealth() {
return this.providerService.getProvidersHealth().map(({ error, ...provider }) => ({
...provider,
...(error ? { errorCode: 'provider_unavailable' } : {}),
}));
}
// ── Credential CRUD ──────────────────────────────────────────────────────
/**

View File

@@ -0,0 +1,13 @@
import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common';
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
/** Maps a consumed/missing runtime approval to a stable HTTP authorization response. */
@Catch(RuntimeApprovalDeniedError)
export class RuntimeApprovalDeniedFilter implements ExceptionFilter {
catch(_exception: RuntimeApprovalDeniedError, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<{
status(code: number): { send(body: { statusCode: number; message: string }): void };
}>();
response.status(403).send({ statusCode: 403, message: 'Runtime termination approval denied' });
}
}

View File

@@ -0,0 +1,500 @@
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import {
createRuntimeAuditLogEntry,
type LogService,
type RuntimeAuditErrorCode,
} from '@mosaicstack/log';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
TransitionalCapabilityInventoryEntry,
TransitionalCapabilityInventoryProvider,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { LOG_SERVICE } from '../log/log.tokens.js';
export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
export type RuntimeProviderOperation =
| RuntimeCapability
| 'runtime.capabilities'
| 'runtime.health'
| 'runtime.transitional-capabilities';
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
export interface RuntimeProviderRequestContext {
actorScope: ActorTenantScope;
channelId: string;
correlationId: string;
}
/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */
export interface RuntimeAuditEvent {
providerId: string;
operation: RuntimeProviderOperation;
outcome: RuntimeProviderAuditOutcome;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
resourceId?: string;
durationMs?: number;
errorCode?: RuntimeAuditErrorCode;
}
export interface RuntimeAuditSink {
record(event: RuntimeAuditEvent): Promise<void>;
}
/** Exact action shape that a durable approval implementation must consume once. */
export interface RuntimeTerminationAction {
providerId: string;
sessionId: string;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
agentName: string;
}
export interface RuntimeApprovalVerifier {
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
}
function isTransitionalInventoryProvider(
provider: AgentRuntimeProvider,
): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider {
return (
typeof (provider as Partial<TransitionalCapabilityInventoryProvider>)
.transitionalCapabilityMatrix === 'function'
);
}
function configuredAgentName(): string {
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!agentName) throw new RuntimeApprovalDeniedError();
return agentName;
}
export class RuntimeApprovalDeniedError extends Error {
constructor() {
super('Runtime termination approval denied');
}
}
/**
* The default denies all runtime termination until a durable, exact-action
* approval implementation is configured. This is safer than a permissive stub.
*/
@Injectable()
export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise<boolean> {
return false;
}
}
/**
* Temporary metadata-only audit sink. M1 observability can replace this token
* with a durable audit writer without changing provider call sites.
*/
@Injectable()
export class RuntimeProviderAuditService implements RuntimeAuditSink {
private readonly logger = new Logger(RuntimeProviderAuditService.name);
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
async record(event: RuntimeAuditEvent): Promise<void> {
const entry = createRuntimeAuditLogEntry(event);
await this.logService.logs.ingest(entry);
this.logger.log(JSON.stringify({ event: entry.content, metadata: entry.metadata }));
}
}
@Injectable()
export class RuntimeProviderService {
private readonly logger = new Logger(RuntimeProviderService.name);
constructor(
@Inject(AGENT_RUNTIME_PROVIDER_REGISTRY)
private readonly registry: AgentRuntimeProviderRegistry,
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
private readonly audit: RuntimeAuditSink,
@Inject(RUNTIME_APPROVAL_VERIFIER)
private readonly approvals: RuntimeApprovalVerifier,
) {}
async capabilities(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeCapabilitySet> {
return this.execute(
providerId,
'runtime.capabilities',
undefined,
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeCapabilitySet> =>
provider.capabilities(scope),
);
}
async health(providerId: string, context: RuntimeProviderRequestContext): Promise<RuntimeHealth> {
return this.execute(
providerId,
'runtime.health',
undefined,
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeHealth> =>
provider.health(scope),
);
}
async transitionalCapabilityMatrix(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<TransitionalCapabilityInventoryEntry[]> {
return this.execute(
providerId,
'runtime.transitional-capabilities',
undefined,
undefined,
context,
async (provider: AgentRuntimeProvider, scope: RuntimeScope) => {
if (!isTransitionalInventoryProvider(provider)) {
throw new NotFoundException('Runtime provider has no transitional capability inventory');
}
return provider.transitionalCapabilityMatrix(scope);
},
);
}
async listSessions(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeSession[]> {
return this.execute(
providerId,
'session.list',
'session.list',
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSession[]> =>
provider.listSessions(scope),
);
}
async getSessionTree(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeSessionTree[]> {
return this.execute(
providerId,
'session.tree',
'session.tree',
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSessionTree[]> =>
provider.getSessionTree(scope),
);
}
streamSession(
providerId: string,
sessionId: string,
cursor: string | undefined,
context: RuntimeProviderRequestContext,
): AsyncIterable<RuntimeStreamEvent> {
return this.stream(
providerId,
'session.stream',
'session.stream',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable<RuntimeStreamEvent> =>
provider.streamSession(sessionId, cursor, scope),
);
}
async sendMessage(
providerId: string,
sessionId: string,
message: RuntimeMessage,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.send',
'session.send',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
provider.sendMessage(sessionId, message, scope),
);
}
async attach(
providerId: string,
sessionId: string,
mode: RuntimeAttachMode,
context: RuntimeProviderRequestContext,
): Promise<RuntimeAttachHandle> {
return this.execute(
providerId,
'session.attach',
'session.attach',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeAttachHandle> =>
provider.attach(sessionId, mode, scope),
);
}
async detach(
providerId: string,
attachmentId: string,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.attach',
'session.attach',
attachmentId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
provider.detach(attachmentId, scope),
);
}
async terminate(
providerId: string,
sessionId: string,
approvalRef: string,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.terminate',
'session.terminate',
sessionId,
context,
async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> => {
const approved = await this.approvals.consume(approvalRef, {
providerId,
sessionId,
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
agentName: configuredAgentName(),
});
if (!approved) {
throw new RuntimeApprovalDeniedError();
}
await provider.terminate(sessionId, approvalRef, scope);
},
);
}
private async execute<T>(
providerId: string,
operation: RuntimeProviderOperation,
requiredCapability: RuntimeCapability | undefined,
resourceId: string | undefined,
context: RuntimeProviderRequestContext,
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
): Promise<T> {
const scope = this.deriveScope(context);
const startedAt = Date.now();
await this.record(providerId, operation, 'requested', scope, resourceId);
let invocationStarted = false;
try {
const provider = this.provider(providerId);
if (requiredCapability) {
await this.assertCapability(provider, requiredCapability, scope);
}
invocationStarted = true;
const result = await invoke(provider, scope);
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
return result;
} catch (error: unknown) {
const durationMs = Date.now() - startedAt;
if (invocationStarted && !this.isAuthorizationDenied(error)) {
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
} else {
await this.record(
providerId,
operation,
'denied',
scope,
resourceId,
durationMs,
'policy_denied',
);
}
throw error;
}
}
private async *stream(
providerId: string,
operation: RuntimeProviderOperation,
requiredCapability: RuntimeCapability,
resourceId: string,
context: RuntimeProviderRequestContext,
invoke: (
provider: AgentRuntimeProvider,
scope: RuntimeScope,
) => AsyncIterable<RuntimeStreamEvent>,
): AsyncIterable<RuntimeStreamEvent> {
const scope = this.deriveScope(context);
const startedAt = Date.now();
await this.record(providerId, operation, 'requested', scope, resourceId);
let invocationStarted = false;
try {
const provider = this.provider(providerId);
await this.assertCapability(provider, requiredCapability, scope);
invocationStarted = true;
for await (const event of invoke(provider, scope)) {
yield event;
}
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
} catch (error: unknown) {
const durationMs = Date.now() - startedAt;
if (invocationStarted) {
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
} else {
await this.record(
providerId,
operation,
'denied',
scope,
resourceId,
durationMs,
'policy_denied',
);
}
throw error;
}
}
private isAuthorizationDenied(error: unknown): boolean {
return (
error instanceof RuntimeApprovalDeniedError ||
error instanceof ForbiddenException ||
(typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'forbidden')
);
}
private provider(providerId: string): AgentRuntimeProvider {
try {
return this.registry.require(providerId);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Runtime provider is not registered';
throw new NotFoundException(message);
}
}
private async assertCapability(
provider: AgentRuntimeProvider,
requiredCapability: RuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
const capabilities = await provider.capabilities(scope);
if (!capabilities.supported.includes(requiredCapability)) {
throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`);
}
}
private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope {
const actorId = context.actorScope.userId.trim();
const tenantId = context.actorScope.tenantId.trim();
const channelId = context.channelId.trim();
const correlationId = context.correlationId.trim();
if (!actorId || !tenantId || !channelId || !correlationId) {
throw new ForbiddenException(
'Authenticated runtime actor scope and correlation are required',
);
}
return Object.freeze({ actorId, tenantId, channelId, correlationId });
}
private async recordFailure(
providerId: string,
operation: RuntimeProviderOperation,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs: number,
): Promise<void> {
try {
await this.record(
providerId,
operation,
'failed',
scope,
resourceId,
durationMs,
'provider_error',
);
} catch {
this.logger.error(
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
);
}
}
private async recordCompletion(
providerId: string,
operation: RuntimeProviderOperation,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs: number,
): Promise<void> {
try {
await this.record(providerId, operation, 'succeeded', scope, resourceId, durationMs);
} catch {
this.logger.error(
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
);
}
}
private async record(
providerId: string,
operation: RuntimeProviderOperation,
outcome: RuntimeProviderAuditOutcome,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs?: number,
errorCode?: RuntimeAuditErrorCode,
): Promise<void> {
await this.audit.record({
providerId,
operation,
outcome,
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
...(resourceId ? { resourceId } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
...(errorCode ? { errorCode } : {}),
});
}
}

View File

@@ -10,6 +10,8 @@ import {
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { AgentService } from './agent.service.js';
@Controller('api/sessions')
@@ -18,23 +20,24 @@ export class SessionsController {
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Get()
list() {
const sessions = this.agentService.listSessions();
list(@CurrentUser() user: AuthenticatedUserLike) {
const sessions = this.agentService.listSessions(scopeFromUser(user));
return { sessions, total: sessions.length };
}
@Get(':id')
findOne(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
if (!info) throw new NotFoundException('Session not found');
return info;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async destroy(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const scope = scopeFromUser(user);
const info = this.agentService.getSessionInfo(id, scope);
if (!info) throw new NotFoundException('Session not found');
await this.agentService.destroySession(id);
await this.agentService.destroySession(id, scope);
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { createMemoryTools } from './memory-tools.js';
describe('createMemoryTools operator retrieval binding', () => {
const memory = {
insights: { searchByEmbedding: vi.fn(), create: vi.fn() },
preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() },
};
const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' };
it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => {
const plugin = {
search: vi.fn(async () => []),
capture: vi.fn(async () => ({ id: 'insight-1' })),
};
const tools = createMemoryTools(memory as never, null, 'owner-a', {
plugin: plugin as never,
scope,
});
await tools
.find((tool) => tool.name === 'memory_search')!
.execute('call-1', { query: 'plans' }, undefined, undefined, {} as never);
await tools
.find((tool) => tool.name === 'memory_save_insight')!
.execute(
'call-2',
{ content: 'secret', category: 'decision' },
undefined,
undefined,
{} as never,
);
expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5);
expect(plugin.capture).toHaveBeenCalledWith(scope, {
content: 'secret',
source: 'agent',
category: 'decision',
});
});
});

View File

@@ -1,7 +1,11 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { Memory } from '@mosaicstack/memory';
import type { EmbeddingProvider } from '@mosaicstack/memory';
import type {
EmbeddingProvider,
Memory,
OperatorMemoryPlugin,
OperatorMemoryScope,
} from '@mosaicstack/memory';
/**
* Create memory tools bound to the session's authenticated userId.
@@ -13,8 +17,10 @@ import type { EmbeddingProvider } from '@mosaicstack/memory';
export function createMemoryTools(
memory: Memory,
embeddingProvider: EmbeddingProvider | null,
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
sessionUserId: string | undefined,
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
): ToolDefinition[] {
/** Return an error result when no session user is bound. */
function noUserError() {
@@ -46,6 +52,14 @@ export function createMemoryTools(
limit?: number;
};
if (operatorMemory) {
const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5);
return {
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
details: undefined,
};
}
if (!embeddingProvider) {
return {
content: [
@@ -158,6 +172,18 @@ export function createMemoryTools(
};
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
if (operatorMemory) {
const insight = await operatorMemory.plugin.capture(operatorMemory.scope, {
content,
source: 'agent',
category: category ?? 'learning',
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
details: undefined,
};
}
let embedding: number[] | null = null;
if (embeddingProvider) {
embedding = await embeddingProvider.embed(content);

View File

@@ -0,0 +1,24 @@
export interface AuthenticatedUserLike {
id: string;
tenantId?: string | null;
teamId?: string | null;
organizationId?: string | null;
orgId?: string | null;
}
export interface ActorTenantScope {
userId: string;
tenantId: string;
}
/**
* Build the immutable server-derived scope used for Tess session operations.
* Current Mosaic auth is user-scoped; future org/team claims can populate one
* of the tenant fields without allowing clients to choose another tenant.
*/
export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope {
return {
userId: user.id,
tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id,
};
}

View File

@@ -12,7 +12,8 @@ describe('Chat controller source hardening', () => {
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect(source).toContain('@UseGuards(AuthGuard)');
expect(source).toContain('@CurrentUser() user: { id: string }');
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
expect(source).toContain('const scope = scopeFromUser(user);');
});
});

View File

@@ -3,8 +3,10 @@ import {
Post,
Body,
Logger,
ForbiddenException,
HttpException,
HttpStatus,
NotFoundException,
Inject,
UseGuards,
} from '@nestjs/common';
@@ -13,6 +15,7 @@ import { Throttle } from '@nestjs/throttler';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { v4 as uuid } from 'uuid';
import { ChatRequestDto } from './chat.dto.js';
@@ -32,16 +35,23 @@ export class ChatController {
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async chat(
@Body() body: ChatRequestDto,
@CurrentUser() user: { id: string },
@CurrentUser() user: AuthenticatedUserLike,
): Promise<ChatResponse> {
const conversationId = body.conversationId ?? uuid();
const scope = scopeFromUser(user);
try {
let agentSession = this.agentService.getSession(conversationId);
let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId);
agentSession = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
});
}
} catch (err) {
if (err instanceof ForbiddenException) {
throw new NotFoundException('Session not found');
}
this.logger.error(
`Session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
@@ -60,20 +70,27 @@ export class ChatController {
reject(new Error('Agent response timed out'));
}, 120_000);
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
clearTimeout(timer);
cleanup();
resolve();
}
});
const cleanup = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
if (
event.type === 'message_update' &&
event.assistantMessageEvent.type === 'text_delta'
) {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
clearTimeout(timer);
cleanup();
resolve();
}
},
scope,
);
});
try {
await this.agentService.prompt(conversationId, body.content);
await this.agentService.prompt(conversationId, body.content, scope);
await done;
} catch (err) {
if (err instanceof HttpException) throw err;

View File

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

View File

@@ -1,3 +1,4 @@
import { timingSafeEqual } from 'node:crypto';
import type { IncomingHttpHeaders } from 'node:http';
import { fromNodeHeaders } from 'better-auth/node';
@@ -12,6 +13,19 @@ export interface SessionAuth {
};
}
export function validateDiscordServiceToken(
candidate: unknown,
expected: string | undefined,
): boolean {
if (typeof candidate !== 'string' || !expected) return false;
const candidateBuffer = Buffer.from(candidate);
const expectedBuffer = Buffer.from(expected);
return (
candidateBuffer.length === expectedBuffer.length &&
timingSafeEqual(candidateBuffer, expectedBuffer)
);
}
export async function validateSocketSession(
headers: IncomingHttpHeaders,
auth: SessionAuth,

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { ChatGateway } from './chat.gateway.js';
const payload: SlashCommandPayload = {
command: 'gc',
conversationId: 'conversation-1',
approvalId: 'approval-1',
};
function buildGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
return new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
commandExecutor as never,
{} as never,
);
}
describe('ChatGateway command approval ingress', () => {
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
createApproval: vi.fn(),
};
const gateway = buildGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, payload);
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, {
userId: 'admin-1',
tenantId: 'admin-1',
});
expect(client.emit).toHaveBeenCalledWith(
'command:result',
expect.objectContaining({ success: true }),
);
});
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn(),
createApproval: vi.fn().mockResolvedValue({
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
}),
};
const gateway = buildGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandApproval(client as never, {
command: 'gc',
conversationId: 'conversation-1',
});
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
{ command: 'gc', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith('command:approval', {
command: 'gc',
conversationId: 'conversation-1',
success: true,
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
});
});
});

View File

@@ -0,0 +1,221 @@
import { describe, expect, it, vi } from 'vitest';
import { ChatGateway } from './chat.gateway.js';
const CONVERSATION_ID = 'conversation-1';
const CANARY = 'sk_canary12345678';
function clientConversationKey(clientId: string, conversationId: string): string {
return `${clientId}\u0000${conversationId}`;
}
type GatewayInternals = {
clientSessions: Map<string, unknown>;
relayEvent(client: unknown, conversationId: string, event: unknown): void;
};
function buildGateway() {
const brain = {
conversations: {
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
return { gateway: gateway as unknown as GatewayInternals, brain };
}
describe('ChatGateway redaction boundary', (): void => {
it('redacts a secret split across assistant deltas before egress and persistence', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
const session = {
clientId: client.id,
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: '',
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
};
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session);
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' },
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary');
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET] ',
});
expect(session.assistantText).toBe(`${CANARY} `);
expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY);
});
it('retains a split secret label until its value can be redacted', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'token ' },
});
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET] ',
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123');
});
it('holds a streamed private key until it can be redacted', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' },
});
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET]',
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary');
});
it('drops an oversized unterminated stream fragment rather than retaining it', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_STREAM_OVERFLOW]',
});
});
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 => {
const { gateway, brain } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), {
clientId: client.id,
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: CANARY,
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
});
gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' });
expect(brain.conversations.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: '[REDACTED_SECRET]',
metadata: expect.objectContaining({ classifications: ['secret'] }),
}),
'user-1',
);
expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
import { CommandAuthorizationService } from './command-authorization.service.js';
const adminCommand: CommandDef = {
name: 'gc',
description: 'GC',
aliases: [],
scope: 'admin',
execution: 'socket',
available: true,
};
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
function createService(
role: string,
entries: Map<string, string> = new Map<string, string>(),
): CommandAuthorizationService {
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => {
entries.set(key, value);
},
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
describe('CommandAuthorizationService', () => {
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
const service = createService('admin');
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
expect(approval).not.toBeNull();
expect(
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
).toBe(true);
expect(
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
).toBe(false);
});
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
const service = createService('admin');
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
const mutated = { ...payload, conversationId: 'other-conversation' };
expect(approval).not.toBeNull();
expect(
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
).toBe(false);
});
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
const service = createService('member');
const approval = await service.createApproval(adminCommand, payload, 'member-1');
expect(approval).toBeNull();
expect(
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
).toBe(false);
});
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
providerId: 'fleet',
sessionId: 'nova',
actorId: 'admin-1',
tenantId: 'tenant-1',
channelId: 'discord:operator',
correlationId: 'correlation-malformed-expiry',
agentName: 'Nova',
};
const service = createService('admin', entries);
const approval = await service.createRuntimeTerminationApproval(action);
expect(approval).not.toBeNull();
const key = `agent:Nova:command-approval:${approval!.approvalId}`;
const stored = entries.get(key);
expect(stored).toBeDefined();
entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' }));
expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
false,
);
});
it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
providerId: 'fleet',
sessionId: 'nova',
actorId: 'admin-1',
tenantId: 'tenant-1',
channelId: 'discord:operator',
correlationId: 'correlation-1',
agentName: 'Nova',
};
const beforeRestart = createService('admin', entries);
const approval = await beforeRestart.createRuntimeTerminationApproval(action);
const afterRestart = createService('admin', entries);
expect(
await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, {
...action,
sessionId: 'forged-session',
}),
).toBe(false);
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
true,
);
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
false,
);
});
});

View File

@@ -0,0 +1,268 @@
import { createHash, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
import { DB } from '../database/database.module.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
export type CommandRole = 'admin' | 'member' | 'viewer';
export interface CommandApproval {
approvalId: string;
actionDigest: string;
actorId: string;
command: string;
expiresAt: string;
}
/** Exact immutable binding for a privileged runtime termination. */
export interface RuntimeTerminationApprovalAction {
providerId: string;
sessionId: string;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
/** Provisioned roster identity; isolates approvals between interaction agents. */
agentName: string;
}
export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction {
approvalId: string;
actionDigest: string;
expiresAt: string;
}
export interface CommandAuthorizationResult {
allowed: boolean;
reason?: string;
}
@Injectable()
export class CommandAuthorizationService {
constructor(
@Inject(DB) private readonly db: Db,
@Inject(COMMANDS_REDIS)
private readonly redis: {
get(key: string): Promise<string | null>;
set(key: string, value: string, ...args: string[]): Promise<unknown>;
del(key: string): Promise<number>;
},
) {}
async authorize(
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
approvalId?: string,
): Promise<CommandAuthorizationResult> {
const role = await this.resolveRole(actorId);
if (!role || !this.hasScope(role, command.scope)) {
return { allowed: false, reason: 'not authorized for this command scope' };
}
if (command.scope !== 'admin') return { allowed: true };
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
const actionDigest = this.actionDigest(command.name, payload);
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
return approved
? { allowed: true }
: {
allowed: false,
reason: 'approval is invalid, expired, replayed, or does not match this action',
};
}
async createApproval(
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
): Promise<CommandApproval | null> {
const role = await this.resolveRole(actorId);
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
const approvalId = randomUUID();
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
const approval: CommandApproval = {
approvalId,
actionDigest: this.actionDigest(command.name, payload),
actorId,
command: command.name,
expiresAt,
};
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
return approval;
}
/**
* Uses the same `interaction:command-approval:*` store and one-time deletion rule as
* command approvals. This deliberately avoids a parallel approval database.
*/
async createRuntimeTerminationApproval(
action: RuntimeTerminationApprovalAction,
): Promise<RuntimeTerminationApproval | null> {
if (!this.hasRuntimeTerminationAction(action)) return null;
const role = await this.resolveRole(action.actorId);
if (role !== 'admin') return null;
const approval: RuntimeTerminationApproval = {
approvalId: randomUUID(),
actionDigest: this.runtimeActionDigest(action),
...action,
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
};
await this.redis.set(
this.runtimeKey(action.agentName, approval.approvalId),
JSON.stringify(approval),
'EX',
'300',
);
return approval;
}
async consumeRuntimeTerminationApproval(
approvalId: string,
action: RuntimeTerminationApprovalAction,
): Promise<boolean> {
const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId));
if (!encoded) return false;
let approval: unknown;
try {
approval = JSON.parse(encoded);
} catch {
return false;
}
if (
!this.isRuntimeTerminationApproval(approval) ||
approval.actionDigest !== this.runtimeActionDigest(action) ||
approval.actorId !== action.actorId ||
approval.tenantId !== action.tenantId ||
!this.isUnexpired(approval.expiresAt)
) {
return false;
}
if ((await this.resolveRole(approval.actorId)) !== 'admin') return false;
return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1;
}
private async resolveRole(actorId: string): Promise<CommandRole | null> {
const [user] = await this.db
.select({ role: usersTable.role })
.from(usersTable)
.where(eq(usersTable.id, actorId))
.limit(1);
const role = user?.role;
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
}
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
if (role === 'admin') return true;
return role === 'member' && (scope === 'core' || scope === 'agent');
}
private async consumeApproval(
approvalId: string,
actorId: string,
actionDigest: string,
): Promise<boolean> {
const key = this.key(approvalId);
const encoded = await this.redis.get(key);
if (!encoded) return false;
let parsed: unknown;
try {
parsed = JSON.parse(encoded);
} catch {
return false;
}
if (
!this.isCommandApproval(parsed) ||
parsed.actorId !== actorId ||
parsed.actionDigest !== actionDigest ||
!this.isUnexpired(parsed.expiresAt)
)
return false;
return (await this.redis.del(key)) === 1;
}
private actionDigest(command: string, payload: SlashCommandPayload): string {
return createHash('sha256')
.update(
JSON.stringify({
command,
args: payload.args?.trim() ?? '',
conversationId: payload.conversationId,
}),
)
.digest('hex');
}
private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean {
return [
action.providerId,
action.sessionId,
action.actorId,
action.tenantId,
action.channelId,
action.correlationId,
action.agentName,
].every((value: string): boolean => value.trim().length > 0);
}
private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string {
return createHash('sha256')
.update(
JSON.stringify({
providerId: action.providerId,
sessionId: action.sessionId,
actorId: action.actorId,
tenantId: action.tenantId,
channelId: action.channelId,
correlationId: action.correlationId,
agentName: action.agentName,
}),
)
.digest('hex');
}
private isUnexpired(expiresAt: unknown): expiresAt is string {
if (typeof expiresAt !== 'string') return false;
const expiresAtMs = Date.parse(expiresAt);
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
}
private isCommandApproval(value: unknown): value is CommandApproval {
return (
typeof value === 'object' &&
value !== null &&
'approvalId' in value &&
'actionDigest' in value &&
'actorId' in value &&
'expiresAt' in value &&
'command' in value
);
}
private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval {
return (
typeof value === 'object' &&
value !== null &&
'approvalId' in value &&
'actionDigest' in value &&
'actorId' in value &&
'tenantId' in value &&
'providerId' in value &&
'sessionId' in value &&
'channelId' in value &&
'correlationId' in value &&
'agentName' in value &&
'expiresAt' in value
);
}
private key(approvalId: string): string {
return `interaction:command-approval:${approvalId}`;
}
private runtimeKey(agentName: string, approvalId: string): string {
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
}
}

View File

@@ -89,6 +89,7 @@ function buildService(): CommandExecutorService {
describe('CommandExecutorService — P8-012 commands', () => {
let service: CommandExecutorService;
const userId = 'user-123';
const userScope = { userId, tenantId: userId };
const conversationId = 'conv-456';
beforeEach(() => {
@@ -99,31 +100,26 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider login — missing provider name
it('/provider login with no provider name returns usage error', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Usage: /provider login');
expect(result.command).toBe('provider');
});
// /provider login anthropic — success with URL containing poll token
it('/provider login <name> returns success with URL and poll token', async () => {
// /provider login anthropic — no bearer token or auth URL reaches chat output
it('/provider login <name> keeps its one-time token out of chat output', async () => {
const payload: SlashCommandPayload = {
command: 'provider',
args: 'login anthropic',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('provider');
expect(result.message).toContain('anthropic');
expect(result.message).toContain('http');
// data should contain loginUrl and pollToken
expect(result.data).toBeDefined();
const data = result.data as Record<string, unknown>;
expect(typeof data['loginUrl']).toBe('string');
expect(typeof data['pollToken']).toBe('string');
expect(data['loginUrl'] as string).toContain('anthropic');
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
expect(result.message).not.toContain('http');
expect(result.message).not.toContain('token=');
expect(result.data).toEqual({ provider: 'anthropic' });
// Verify Valkey was called
expect(mockRedis.set).toHaveBeenCalledOnce();
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
@@ -138,7 +134,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider with no args — returns usage
it('/provider with no args returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'provider', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage: /provider');
});
@@ -146,7 +142,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider list
it('/provider list returns success', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('provider');
});
@@ -154,7 +150,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider logout with no name — usage error
it('/provider logout with no name returns error', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Usage: /provider logout');
});
@@ -166,7 +162,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'unknown',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Unknown subcommand');
});
@@ -174,7 +170,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /mission status
it('/mission status returns stub message', async () => {
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('mission');
expect(result.message).toContain('Mission status');
@@ -183,7 +179,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /mission with no args
it('/mission with no args returns status stub', async () => {
const payload: SlashCommandPayload = { command: 'mission', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Mission status');
});
@@ -195,7 +191,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'set my-mission-123',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('my-mission-123');
});
@@ -203,7 +199,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /agent list
it('/agent list returns stub message', async () => {
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('agent');
expect(result.message).toContain('agent');
@@ -212,7 +208,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /agent with no args
it('/agent with no args returns usage', async () => {
const payload: SlashCommandPayload = { command: 'agent', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage: /agent');
});
@@ -224,7 +220,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'my-agent-id',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('my-agent-id');
});
@@ -232,7 +228,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /prdy
it('/prdy returns PRD wizard message', async () => {
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('prdy');
expect(result.message).toContain('mosaic prdy');
@@ -241,7 +237,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /tools
it('/tools returns tools stub message', async () => {
const payload: SlashCommandPayload = { command: 'tools', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('tools');
expect(result.message).toContain('tools');

View File

@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js';
const registry = {
getManifest: vi.fn(() => ({
version: 1,
commands: [
{
name: 'gc',
description: 'System-wide garbage collection',
aliases: [],
scope: 'admin' as const,
execution: 'socket' as const,
available: true,
},
],
skills: [],
})),
};
const sessionGc = {
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }),
};
const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' });
const authorization = {
authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) =>
Promise.resolve(
actorId === 'member-1'
? { allowed: false, reason: 'durable approval is required' }
: { allowed: false, reason: 'not authorized for this command scope' },
),
),
};
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
return new CommandExecutorService(
registry as never,
{ getSession: vi.fn() } as never,
{ clear: vi.fn(), set: vi.fn() } as never,
sessionGc as never,
{ set: vi.fn() } as never,
{ agents: {} } as never,
null,
null,
null,
authorizationService as never,
);
}
function createDurableAuthorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => {
entries.set(key, value);
},
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
beforeEach((): void => {
vi.clearAllMocks();
});
it('denies a forged admin identity and does not execute a system-wide command', async (): Promise<void> => {
const result = await buildExecutor().execute(payload, scope('admin-forged-by-client'));
expect(result.success).toBe(false);
expect(result.message).toContain('not authorized');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
it('denies a privileged command without a server-bound durable approval', async (): Promise<void> => {
const result = await buildExecutor().execute(payload, scope('member-1'));
expect(result.success).toBe(false);
expect(result.message).toContain('approval');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
const executor = buildExecutor(createDurableAuthorization());
const adminScope = scope('admin-1');
const denied = await executor.execute(payload, adminScope);
const approval = await executor.createApproval(payload, adminScope);
const approved = await executor.execute(
{ ...payload, approvalId: approval?.approvalId },
adminScope,
);
expect(denied.success).toBe(false);
expect(denied.message).toContain('approval');
expect(approval).not.toBeNull();
// A valid durable approval is consumed, but cannot authorize an unimplemented
// global retention operation. Session-scoped cleanup remains lifecycle-only.
expect(approved.success).toBe(false);
expect(approved.message).toContain('Global GC is disabled');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,7 @@ import type { QueueHandle } from '@mosaicstack/queue';
import type { Brain } from '@mosaicstack/brain';
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
import { AgentService } from '../agent/agent.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { ChatGateway } from '../chat/chat.gateway.js';
import { SessionGCService } from '../gc/session-gc.service.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
@@ -10,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js';
import { McpClientService } from '../mcp-client/mcp-client.service.js';
import { BRAIN } from '../brain/brain.tokens.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandRegistryService } from './command-registry.service.js';
@Injectable()
@@ -32,10 +34,17 @@ export class CommandExecutorService {
@Optional()
@Inject(McpClientService)
private readonly mcpClient: McpClientService | null,
@Optional()
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService | null = null,
) {}
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
async execute(
payload: SlashCommandPayload,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
const { command, args, conversationId } = payload;
const userId = scope.userId;
const def = this.registry.getManifest().commands.find((c) => c.name === command);
if (!def) {
@@ -47,14 +56,24 @@ export class CommandExecutorService {
};
}
const authorization = await this.authorization?.authorize(
def,
payload,
userId,
payload.approvalId,
);
if (authorization && !authorization.allowed) {
return { command, conversationId, success: false, message: authorization.reason };
}
try {
switch (command) {
case 'model':
return await this.handleModel(args ?? null, conversationId);
return await this.handleModel(args ?? null, conversationId, scope);
case 'thinking':
return await this.handleThinking(args ?? null, conversationId);
case 'system':
return await this.handleSystem(args ?? null, conversationId);
return await this.handleSystem(args ?? null, conversationId, scope);
case 'new':
return {
command,
@@ -93,7 +112,7 @@ export class CommandExecutorService {
conversationId,
};
case 'agent':
return await this.handleAgent(args ?? null, conversationId, userId);
return await this.handleAgent(args ?? null, conversationId, scope);
case 'provider':
return await this.handleProvider(args ?? null, userId, conversationId);
case 'mission':
@@ -142,13 +161,22 @@ export class CommandExecutorService {
}
}
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
const def = this.registry
.getManifest()
.commands.find((command) => command.name === payload.command);
if (!def || !this.authorization) return null;
return this.authorization.createApproval(def, payload, scope.userId);
}
private async handleModel(
args: string | null,
conversationId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
if (!args || args.trim().length === 0) {
// Show current override or usage hint
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
if (currentOverride) {
return {
command: 'model',
@@ -170,7 +198,7 @@ export class CommandExecutorService {
// /model clear removes the override and re-enables automatic routing
if (modelName === 'clear') {
this.chatGateway?.setModelOverride(conversationId, null);
this.chatGateway?.setModelOverride(conversationId, null, scope);
return {
command: 'model',
conversationId,
@@ -180,9 +208,9 @@ export class CommandExecutorService {
}
// Set the sticky per-session override (M4-007)
this.chatGateway?.setModelOverride(conversationId, modelName);
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
const session = this.agentService.getSession(conversationId);
const session = this.agentService.getSession(conversationId, scope);
if (!session) {
return {
command: 'model',
@@ -223,10 +251,11 @@ export class CommandExecutorService {
private async handleSystem(
args: string | null,
conversationId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
if (!args || args.trim().length === 0) {
// Clear the override when called with no args
await this.systemOverride.clear(conversationId);
await this.systemOverride.clear(conversationId, scope);
return {
command: 'system',
conversationId,
@@ -235,7 +264,7 @@ export class CommandExecutorService {
};
}
await this.systemOverride.set(conversationId, args.trim());
await this.systemOverride.set(conversationId, args.trim(), scope);
return {
command: 'system',
conversationId,
@@ -247,8 +276,9 @@ export class CommandExecutorService {
private async handleAgent(
args: string | null,
conversationId: string,
userId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
const userId = scope.userId;
if (!args) {
return {
command: 'agent',
@@ -337,11 +367,14 @@ export class CommandExecutorService {
conversationId,
agentConfig.id,
agentConfig.name,
scope,
agentConfig.model ?? undefined,
);
// Broadcast updated session:info so TUI TopBar reflects new agent/model
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
agentName: agentConfig.name,
});
this.logger.log(
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
@@ -402,22 +435,28 @@ export class CommandExecutorService {
};
}
const pollToken = crypto.randomUUID();
const key = `mosaic:auth:poll:${pollToken}`;
// Store pending state in Valkey (TTL 5 minutes)
const tokenDigest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(pollToken),
);
const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string =>
byte.toString(16).padStart(2, '0'),
).join('');
const key = `mosaic:auth:poll:${tokenHash}`;
// Persist only a short-lived token digest. The raw token is delivered only by
// the authenticated dashboard flow, never in chat output or command metadata.
await this.redis.set(
key,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
// In production this would construct an OAuth URL
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
return {
command: 'provider',
success: true,
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
conversationId,
data: { loginUrl, pollToken, provider: providerName },
data: { provider: providerName },
};
}

View File

@@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => {
let registry: CommandRegistryService;
let executor: CommandExecutorService;
const userId = 'user-integ-001';
const userScope = { userId, tenantId: userId };
const conversationId = 'conv-integ-001';
beforeEach(() => {
@@ -170,7 +171,7 @@ describe('CommandExecutorService — integration', () => {
// Unknown command returns error
it('unknown command returns success:false with descriptive message', async () => {
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('nonexistent');
expect(result.command).toBe('nonexistent');
@@ -178,7 +179,7 @@ describe('CommandExecutorService — integration', () => {
it('/gc refuses an unaudited global sweep', async () => {
const payload: SlashCommandPayload = { command: 'gc', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled();
expect(result.success).toBe(false);
expect(result.message).toContain('disabled pending an authorized retention job');
@@ -188,8 +189,8 @@ describe('CommandExecutorService — integration', () => {
it('/system with text calls SystemOverrideService.set', async () => {
const override = 'You are a helpful assistant.';
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
const result = await executor.execute(payload, userId);
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
const result = await executor.execute(payload, userScope);
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('override set');
});
@@ -197,8 +198,8 @@ describe('CommandExecutorService — integration', () => {
// /system with no args clears the override
it('/system with no args calls SystemOverrideService.clear', async () => {
const payload: SlashCommandPayload = { command: 'system', conversationId };
const result = await executor.execute(payload, userId);
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
const result = await executor.execute(payload, userScope);
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('cleared');
});
@@ -210,7 +211,7 @@ describe('CommandExecutorService — integration', () => {
args: 'claude-3-opus',
conversationId,
};
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('model');
expect(result.message).toContain('claude-3-opus');
@@ -219,7 +220,7 @@ describe('CommandExecutorService — integration', () => {
// /thinking with valid level returns success
it('/thinking with valid level returns success', async () => {
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('high');
});
@@ -227,7 +228,7 @@ describe('CommandExecutorService — integration', () => {
// /thinking with invalid level returns usage message
it('/thinking with invalid level returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage:');
});
@@ -235,7 +236,7 @@ describe('CommandExecutorService — integration', () => {
// /new command returns success
it('/new returns success', async () => {
const payload: SlashCommandPayload = { command: 'new', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('new');
});
@@ -243,7 +244,7 @@ describe('CommandExecutorService — integration', () => {
// /reload without reloadService returns failure
it('/reload without ReloadService returns failure', async () => {
const payload: SlashCommandPayload = { command: 'reload', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('ReloadService');
});
@@ -253,7 +254,7 @@ describe('CommandExecutorService — integration', () => {
for (const cmd of stubCommands) {
it(`/${cmd} returns success (stub)`, async () => {
const payload: SlashCommandPayload = { command: cmd, conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe(cmd);
});

View File

@@ -3,8 +3,10 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js';
import { CommandRegistryService } from './command-registry.service.js';
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
@@ -24,9 +26,16 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
inject: [COMMANDS_QUEUE_HANDLE],
},
CommandRegistryService,
CommandAuthorizationService,
CommandRuntimeApprovalVerifier,
CommandExecutorService,
],
exports: [
CommandRegistryService,
CommandAuthorizationService,
CommandRuntimeApprovalVerifier,
CommandExecutorService,
],
exports: [CommandRegistryService, CommandExecutorService],
})
export class CommandsModule implements OnApplicationShutdown {
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}

View File

@@ -0,0 +1,23 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
RuntimeApprovalVerifier,
RuntimeTerminationAction,
} from '../agent/runtime-provider-registry.service.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
/**
* Adapter from the provider registry's exact termination action to the shared,
* Redis-backed `interaction:command-approval:*` store. It has no separate approval
* persistence or replay semantics.
*/
@Injectable()
export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
constructor(
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService,
) {}
async consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean> {
return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action);
}
}

View File

@@ -1,10 +1,32 @@
import { Module } from '@nestjs/common';
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
import { CoordService } from './coord.service.js';
import { CoordController } from './coord.controller.js';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
import {
COORDINATION_CONFIG,
COORDINATION_PORT,
InteractionCoordinationService,
} from './interaction-coordination.service.js';
@Module({
providers: [CoordService],
controllers: [CoordController],
exports: [CoordService],
providers: [
CoordService,
{
provide: COORDINATION_PORT,
useFactory: (): InMemoryInteractionCoordinationPort =>
new InMemoryInteractionCoordinationPort(),
},
{
provide: COORDINATION_CONFIG,
useFactory: () => ({
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
}),
},
InteractionCoordinationService,
],
controllers: [CoordController, InteractionCoordinationController],
exports: [CoordService, InteractionCoordinationService],
})
export class CoordModule {}

View File

@@ -0,0 +1,47 @@
const PATH_METADATA = 'path';
import { describe, expect, it, vi } from 'vitest';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
const user = { id: 'operator-1', tenantId: 'tenant-a' };
describe('InteractionCoordinationController', () => {
it('exposes the neutral canonical route and Mos compatibility alias over identical handlers', () => {
expect(Reflect.getMetadata(PATH_METADATA, InteractionCoordinationController)).toEqual([
'api/coord/interaction',
'api/coord/mos',
]);
expect(InteractionCoordinationController.prototype.handoff).toBeTypeOf('function');
expect(InteractionCoordinationController.prototype.observe).toBeTypeOf('function');
expect(InteractionCoordinationController.prototype.result).toBeTypeOf('function');
});
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
const coordination = {
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
observe: vi.fn(),
result: vi.fn(),
};
const controller = new InteractionCoordinationController(coordination as never);
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
expect(coordination.handoff).toHaveBeenCalledWith(
{ idempotencyKey: 'request-1', summary: 'Implement' },
expect.objectContaining({
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
channelId: 'cli',
correlationId: 'corr-1',
}),
);
});
it('requires a correlation header before invoking the coordination service', async () => {
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
const controller = new InteractionCoordinationController(coordination as never);
await expect(
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
).rejects.toThrow('X-Correlation-Id is required');
expect(coordination.handoff).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,75 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Inject,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type {
InteractionCoordinationObservationDto,
InteractionCoordinationResponseDto,
InteractionCoordinationResultDto,
CreateHandoffDto,
} from './interaction-coordination.dto.js';
import { InteractionCoordinationService } from './interaction-coordination.service.js';
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
@Controller(['api/coord/interaction', 'api/coord/mos'])
@UseGuards(AuthGuard)
export class InteractionCoordinationController {
constructor(
@Inject(InteractionCoordinationService)
private readonly coordination: InteractionCoordinationService,
) {}
@Post('handoff')
async handoff(
@Body() request: CreateHandoffDto,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<InteractionCoordinationResponseDto> {
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
}
@Get(':handoffId/observe')
async observe(
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<InteractionCoordinationObservationDto> {
return {
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
};
}
@Get(':handoffId/result')
async result(
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<InteractionCoordinationResultDto> {
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
}
private context(
user: AuthenticatedUserLike,
correlationId?: string,
): RuntimeProviderRequestContext {
const requestCorrelationId = correlationId?.trim();
if (!requestCorrelationId) throw new ForbiddenException('X-Correlation-Id is required');
return {
actorScope: scopeFromUser(user),
channelId: 'cli',
correlationId: requestCorrelationId,
};
}
}

View File

@@ -0,0 +1,25 @@
import type {
CoordinationObservation,
CoordinationResult,
HandoffReceipt,
} from '@mosaicstack/coord';
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
export interface CreateHandoffDto {
idempotencyKey: string;
summary: string;
context?: string;
missionId?: string;
}
export interface InteractionCoordinationResponseDto {
receipt: HandoffReceipt;
}
export interface InteractionCoordinationObservationDto {
observation: CoordinationObservation;
}
export interface InteractionCoordinationResultDto {
result: CoordinationResult;
}

View File

@@ -0,0 +1,88 @@
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);
});
});

View File

@@ -0,0 +1,217 @@
import { describe, expect, it, vi } from 'vitest';
import {
InMemoryInteractionCoordinationPort,
type InteractionCoordinationPort,
type Handoff,
} from '@mosaicstack/coord';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import {
InteractionCoordinationService,
type InteractionCoordinationConfig,
type InteractionCoordinationGatewayError,
} from './interaction-coordination.service.js';
const context: RuntimeProviderRequestContext = {
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
channelId: 'cli',
correlationId: 'corr-1',
};
const config: InteractionCoordinationConfig = {
interactionAgentId: 'Nova',
orchestrationAgentId: 'Conductor',
};
function service(
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
options: {
config?: InteractionCoordinationConfig;
handoffIdFactory?: () => string;
} = {},
): InteractionCoordinationService {
return new InteractionCoordinationService(
port,
options.config ?? config,
options.handoffIdFactory ?? (() => 'handoff-1'),
);
}
describe('InteractionCoordinationService authority boundary', (): void => {
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = service(adapter);
await expect(
coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).resolves.toEqual({
handoffId: 'handoff-1',
targetAgentId: 'Conductor',
status: 'queued',
correlationId: 'corr-1',
});
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
const followUpContext = { ...context, correlationId: 'corr-2' };
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
targetAgentId: 'Conductor',
status: 'completed',
});
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
targetAgentId: 'Conductor',
status: 'completed',
summary: 'Merged by orchestrator',
});
});
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter, {
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
});
await expect(
coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).rejects.toMatchObject({
code: 'unconfigured_requester',
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(handoff).not.toHaveBeenCalled();
});
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter, {
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
});
await expect(
coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).rejects.toThrow('Interaction and orchestration identities must differ');
expect(handoff).not.toHaveBeenCalled();
});
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const observe = vi.spyOn(adapter, 'observe');
const result = vi.spyOn(adapter, 'result');
const coordination = service(adapter);
await coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
);
const otherTenant = {
...context,
actorScope: { ...context.actorScope, tenantId: 'tenant-b' },
};
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
code: 'cross_tenant_forbidden',
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
code: 'cross_tenant_forbidden',
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(observe).not.toHaveBeenCalled();
expect(result).not.toHaveBeenCalled();
});
it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise<void> => {
let handoffSequence = 0;
let release: (() => void) | undefined;
const delivered = new Promise<void>((resolve: () => void): void => {
release = resolve;
});
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async (handoff: Handoff) => {
await delivered;
return {
handoffId: handoff.handoffId,
targetAgentId: handoff.targetAgentId,
status: 'queued' as const,
correlationId: handoff.scope.correlationId,
};
}),
observe: vi.fn(),
result: vi.fn(),
};
const coordination = service(adapter, {
handoffIdFactory: (): string => `handoff-${++handoffSequence}`,
});
const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' };
const first = coordination.handoff(request, context);
const retry = coordination.handoff(request, context);
expect(adapter.handoff).toHaveBeenCalledTimes(1);
release?.();
await expect(Promise.all([first, retry])).resolves.toEqual([
expect.objectContaining({ handoffId: 'handoff-1' }),
expect.objectContaining({ handoffId: 'handoff-1' }),
]);
await expect(
coordination.handoff(request, {
...context,
actorScope: { ...context.actorScope, userId: 'operator-2' },
}),
).resolves.toMatchObject({ handoffId: 'handoff-2' });
expect(adapter.handoff).toHaveBeenCalledTimes(2);
});
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter);
await coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
);
await expect(
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
).rejects.toMatchObject({
code: 'handoff_conflict',
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(handoff).toHaveBeenCalledTimes(1);
});
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async (handoff: Handoff) => ({
handoffId: handoff.handoffId,
targetAgentId: 'Unexpected',
status: 'accepted' as const,
correlationId: handoff.scope.correlationId,
})),
observe: vi.fn(),
result: vi.fn(),
};
await expect(
service(adapter).handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).rejects.toMatchObject({ code: 'target_drift' });
});
});

View File

@@ -0,0 +1,303 @@
import { Inject, Injectable } from '@nestjs/common';
import {
InteractionCoordinationClient,
type CoordinationObservation,
type CoordinationResult,
type CoordinationScope,
type InteractionCoordinationIdentity,
type InteractionCoordinationPort,
type HandoffReceipt,
} from '@mosaicstack/coord';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
const MAX_TRACKED_HANDOFFS = 1_000;
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
const MAX_SUMMARY_LENGTH = 2_048;
const MAX_CONTEXT_LENGTH = 8_192;
const MAX_MISSION_ID_LENGTH = 128;
export interface InteractionCoordinationConfig {
interactionAgentId?: string;
orchestrationAgentId?: string;
}
interface HandoffOwner {
actorId: string;
tenantId: string;
requesterAgentId: string;
correlationId: string;
expiresAt: number;
}
interface NormalizedHandoffRequest {
idempotencyKey: string;
summary: string;
context?: string;
missionId?: string;
}
interface TrackedHandoff {
request: NormalizedHandoffRequest;
receipt: Promise<HandoffReceipt>;
expiresAt: number;
}
/**
* Gateway authority boundary for the interaction agent. It derives requester,
* actor, and tenant from trusted server configuration and authentication; no
* channel request can name a target or gain orchestrator-owned orchestration verbs.
*/
@Injectable()
export class InteractionCoordinationService {
private readonly owners = new Map<string, HandoffOwner>();
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
constructor(
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {}
async handoff(
request: CreateHandoffDto,
context: RuntimeProviderRequestContext,
): Promise<HandoffReceipt> {
this.pruneExpiredTracking();
const normalized = this.normalizeRequest(request);
const scope = this.scope(context);
const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope);
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
if (existing !== undefined) {
if (!sameRequest(existing.request, normalized)) {
throw new InteractionCoordinationGatewayError(
'handoff_conflict',
'Handoff idempotency key is already bound to different immutable input',
);
}
return existing.receipt;
}
const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope);
const tracked: TrackedHandoff = {
request: normalized,
receipt: pending,
expiresAt: this.expiresAt(),
};
this.handoffsByIdempotencyKey.set(idempotencyKey, tracked);
this.enforceTrackingLimit(this.handoffsByIdempotencyKey);
try {
return await pending;
} catch (error: unknown) {
if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) {
this.handoffsByIdempotencyKey.delete(idempotencyKey);
}
throw error;
}
}
async observe(
handoffId: string,
context: RuntimeProviderRequestContext,
): Promise<CoordinationObservation> {
this.pruneExpiredTracking();
const scope = this.scope(context);
const owner = this.ownerFor(handoffId, scope);
return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId });
}
async result(
handoffId: string,
context: RuntimeProviderRequestContext,
): Promise<CoordinationResult> {
this.pruneExpiredTracking();
const scope = this.scope(context);
const owner = this.ownerFor(handoffId, scope);
return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId });
}
private async deliverHandoff(
handoffId: string,
request: NormalizedHandoffRequest,
scope: CoordinationScope,
): Promise<HandoffReceipt> {
const receipt = await this.client((): string => handoffId).handoff(request, scope);
const owner: HandoffOwner = {
actorId: scope.actorId,
tenantId: scope.tenantId,
requesterAgentId: scope.requesterAgentId,
correlationId: scope.correlationId,
expiresAt: this.expiresAt(),
};
const existing = this.owners.get(receipt.handoffId);
if (existing !== undefined && !sameOwner(existing, owner)) {
throw new InteractionCoordinationGatewayError(
'handoff_conflict',
'Handoff ID is already bound to a different authenticated scope',
);
}
this.owners.set(receipt.handoffId, owner);
this.enforceTrackingLimit(this.owners);
return receipt;
}
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
}
private identity(): InteractionCoordinationIdentity {
const interactionAgentId = this.config.interactionAgentId?.trim();
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
if (!interactionAgentId) {
throw new InteractionCoordinationGatewayError(
'unconfigured_requester',
'Interaction agent identity is not configured',
);
}
if (!orchestrationAgentId) {
throw new InteractionCoordinationGatewayError(
'unconfigured_target',
'Orchestration agent identity is not configured',
);
}
return { interactionAgentId, orchestrationAgentId };
}
private scope(context: RuntimeProviderRequestContext): CoordinationScope {
const identity = this.identity();
return Object.freeze({
actorId: context.actorScope.userId,
tenantId: context.actorScope.tenantId,
correlationId: context.correlationId,
requesterAgentId: identity.interactionAgentId,
});
}
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
if (typeof request !== 'object' || request === null) {
throw new InteractionCoordinationGatewayError(
'invalid_request',
'Handoff request is invalid',
);
}
const idempotencyKey = this.requiredString(
request.idempotencyKey,
'idempotency key',
MAX_IDEMPOTENCY_KEY_LENGTH,
);
const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH);
const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH);
const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH);
return Object.freeze({
idempotencyKey,
summary,
...(context === undefined ? {} : { context }),
...(missionId === undefined ? {} : { missionId }),
});
}
private idempotencyKey(requestKey: string, scope: CoordinationScope): string {
return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`;
}
private requiredString(value: unknown, field: string, maximumLength: number): string {
if (typeof value !== 'string') {
throw new InteractionCoordinationGatewayError(
'invalid_request',
`Handoff ${field} must be a string`,
);
}
const normalized = value.trim();
if (normalized.length === 0 || normalized.length > maximumLength) {
throw new InteractionCoordinationGatewayError(
'invalid_request',
`Handoff ${field} is invalid`,
);
}
return normalized;
}
private optionalString(value: unknown, field: string, maximumLength: number): string | undefined {
if (value === undefined) return undefined;
return this.requiredString(value, field, maximumLength);
}
private expiresAt(): number {
return Date.now() + HANDOFF_TRACKING_TTL_MS;
}
private pruneExpiredTracking(): void {
const now = Date.now();
for (const [key, tracked] of this.handoffsByIdempotencyKey) {
if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key);
}
for (const [key, owner] of this.owners) {
if (owner.expiresAt <= now) this.owners.delete(key);
}
}
private enforceTrackingLimit<T>(entries: Map<string, T>): void {
while (entries.size > MAX_TRACKED_HANDOFFS) {
const oldest = entries.keys().next().value;
if (typeof oldest !== 'string') return;
entries.delete(oldest);
}
}
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
const owner = this.owners.get(handoffId);
if (owner === undefined) {
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
}
if (
owner.tenantId !== scope.tenantId ||
owner.actorId !== scope.actorId ||
owner.requesterAgentId !== scope.requesterAgentId
) {
throw new InteractionCoordinationGatewayError(
'cross_tenant_forbidden',
'Handoff is outside the authenticated scope',
);
}
return owner;
}
}
export type InteractionCoordinationGatewayErrorCode =
| 'cross_tenant_forbidden'
| 'handoff_conflict'
| 'invalid_request'
| 'not_found'
| 'unconfigured_requester'
| 'unconfigured_target';
function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
return (
left.actorId === right.actorId &&
left.tenantId === right.tenantId &&
left.requesterAgentId === right.requesterAgentId
);
}
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
return (
left.idempotencyKey === right.idempotencyKey &&
left.summary === right.summary &&
left.context === right.context &&
left.missionId === right.missionId
);
}
export class InteractionCoordinationGatewayError extends Error {
constructor(
readonly code: InteractionCoordinationGatewayErrorCode,
message: string,
) {
super(message);
this.name = InteractionCoordinationGatewayError.name;
}
}

View File

@@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log';
import { SessionGCService } from './session-gc.service.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
type MockRedis = {
scan: ReturnType<typeof vi.fn>;
@@ -65,6 +66,59 @@ describe('SessionGCService', () => {
expect(result.cleaned.valkeyKeys).toBeUndefined();
});
it('escapes glob metacharacters in a session identifier', async () => {
await service.collect('abc*?[tenant]\\escape');
expect(mockRedis.scan).toHaveBeenCalledWith(
'0',
'MATCH',
'mosaic:session:abc\\*\\?\\[tenant\\]\\\\escape:*',
'COUNT',
100,
);
});
it('preserves a valid durable approval after session GC', async () => {
const entries = new Map<string, string>();
const redis = {
scan: vi.fn().mockResolvedValue(['0', ['mosaic:session:owned:state']]),
get: vi.fn(async (key: string) => entries.get(key) ?? null),
set: vi.fn(async (key: string, value: string) => entries.set(key, value)),
del: vi.fn(async (...keys: string[]) => {
let deleted = 0;
for (const key of keys) deleted += Number(entries.delete(key));
return deleted;
}),
};
const authorization = new CommandAuthorizationService(
{
select: () => ({
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
}),
} as never,
redis,
);
const command = {
name: 'gc',
description: 'System-wide garbage collection',
aliases: [],
scope: 'admin',
execution: 'socket',
available: true,
} as never;
const payload = { command: 'gc', conversationId: 'owned' };
const approval = await authorization.createApproval(command, payload, 'admin-1');
const approvalKey = `interaction:command-approval:${approval!.approvalId}`;
const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService);
await gc.collect('owned');
expect(entries.has(approvalKey)).toBe(true);
await expect(
authorization.authorize(command, payload, 'admin-1', approval!.approvalId),
).resolves.toEqual({ allowed: true });
});
it('collect() returns sessionId in result', async () => {
const result = await service.collect('test-session-id');
expect(result.sessionId).toBe('test-session-id');

View File

@@ -13,6 +13,11 @@ export interface GCResult {
};
}
/** Escape Redis glob metacharacters so a session identifier is always literal. */
function escapeRedisGlobLiteral(value: string): string {
return value.replace(/[\\*?\[\]]/g, '\\$&');
}
@Injectable()
export class SessionGCService {
constructor(
@@ -43,7 +48,7 @@ export class SessionGCService {
const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys
const pattern = `mosaic:session:${sessionId}:*`;
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { HealthController } from './health.controller.js';
describe('HealthController', (): void => {
it('exposes liveness and readiness without configuration details', (): void => {
const controller = new HealthController();
expect(controller.check()).toEqual({ status: 'ok' });
expect(controller.ready()).toEqual({ status: 'ready' });
expect(JSON.stringify(controller.ready())).not.toContain('credential');
});
});

View File

@@ -6,4 +6,10 @@ export class HealthController {
check(): { status: string } {
return { status: 'ok' };
}
/** Readiness intentionally exposes no configuration, provider, or credential details. */
@Get('ready')
ready(): { status: string } {
return { status: 'ready' };
}
}

View File

@@ -3,7 +3,11 @@ import { Logger } from '@nestjs/common';
import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaicstack/auth';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import type { McpService } from './mcp.service.js';
import {
createMcpActorContext,
deriveMcpToolScopesForUser,
type McpService,
} from './mcp.service.js';
import { AUTH } from '../auth/auth.tokens.js';
/**
@@ -67,14 +71,25 @@ async function handleMcpRequest(
return;
}
const userId = result.user.id;
const authUser = result.user as {
id: string;
role?: string | null;
tenantId?: string | null;
organizationId?: string | null;
};
const actor = createMcpActorContext({
userId: authUser.id,
role: authUser.role,
tenantId: authUser.tenantId ?? authUser.organizationId ?? undefined,
scopes: deriveMcpToolScopesForUser({ role: authUser.role }),
});
// ─── Session routing ─────────────────────────────────────────────────────
const sessionId = req.raw.headers['mcp-session-id'];
if (typeof sessionId === 'string' && sessionId.length > 0) {
// Existing session request
const transport = mcpService.getSession(sessionId);
const transport = mcpService.getSession(sessionId, actor);
if (!transport) {
logger.warn(`MCP session not found: ${sessionId}`);
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
@@ -112,8 +127,10 @@ async function handleMcpRequest(
}
// Create new session and handle this initializing request
const { transport } = mcpService.createSession(userId);
logger.log(`New MCP session created for user ${userId}`);
const { transport } = mcpService.createSession(actor);
logger.log(
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
);
await transport.handleRequest(req.raw, reply.raw, body);
}

View File

@@ -0,0 +1,461 @@
import { describe, expect, it, vi } from 'vitest';
import type { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Brain } from '@mosaicstack/brain';
import type { Memory } from '@mosaicstack/memory';
import type { EmbeddingService } from '../memory/embedding.service.js';
import type { CoordService } from '../coord/coord.service.js';
import {
assertMcpToolAuthorized,
createMcpActorContext,
deriveMcpToolScopesForUser,
MCP_TOOL_SCOPES,
McpService,
type McpToolName,
} from './mcp.service.js';
type ToolResult = { content: Array<{ type: 'text'; text: string }> };
type ToolHandler = (params: Record<string, unknown>) => Promise<ToolResult>;
interface CapturedTool {
inputSchema: z.ZodType;
handler: ToolHandler;
}
function makeCapturingServer(): { server: McpServer; tools: Map<string, CapturedTool> } {
const tools = new Map<string, CapturedTool>();
const server = {
registerTool(name: string, config: { inputSchema: z.ZodType }, handler: ToolHandler): void {
tools.set(name, { inputSchema: config.inputSchema, handler });
},
};
return { server: server as unknown as McpServer, tools };
}
function makeService(opts?: {
projects?: Array<Record<string, unknown> & { id: string; ownerId?: string | null }>;
missions?: Array<
Record<string, unknown> & { id: string; projectId?: string | null; userId?: string | null }
>;
tasks?: Array<
Record<string, unknown> & {
id: string;
projectId?: string | null;
missionId?: string | null;
status?: string;
}
>;
}) {
const projects = opts?.projects ?? [];
const missions = opts?.missions ?? [];
const tasks = opts?.tasks ?? [];
const brain = {
projects: {
findAll: vi.fn(async () => projects),
findById: vi.fn(async (id: string) => projects.find((project) => project.id === id) ?? null),
},
tasks: {
findAll: vi.fn(async () => tasks),
findById: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
findByProject: vi.fn(async (projectId: string) =>
tasks.filter((task) => task.projectId === projectId),
),
findByMission: vi.fn(async (missionId: string) =>
tasks.filter((task) => task.missionId === missionId),
),
findByStatus: vi.fn(async (status: string) => tasks.filter((task) => task.status === status)),
create: vi.fn(async (task: Record<string, unknown>) => ({ id: 'task-1', ...task })),
update: vi.fn(async (id: string, updates: Record<string, unknown>) => ({ id, ...updates })),
},
missions: {
findAll: vi.fn(async () => missions),
findById: vi.fn(async (id: string) => missions.find((mission) => mission.id === id) ?? null),
findByProject: vi.fn(async (projectId: string) =>
missions.filter((mission) => mission.projectId === projectId),
),
},
conversations: {
findAll: vi.fn(async (userId: string) => [{ id: 'conversation-1', userId }]),
},
} as unknown as Brain;
const memory = {
insights: {
searchByEmbedding: vi.fn(async (userId: string) => [{ id: 'insight-1', userId }]),
create: vi.fn(async (insight: Record<string, unknown>) => ({ id: 'insight-2', ...insight })),
},
preferences: {
findByUser: vi.fn(async (userId: string) => [{ key: 'theme', userId }]),
findByUserAndCategory: vi.fn(async (userId: string, category: string) => [
{ key: 'theme', userId, category },
]),
upsert: vi.fn(async (preference: Record<string, unknown>) => ({
id: 'pref-1',
...preference,
})),
},
} as unknown as Memory;
const embeddings = {
available: true,
embed: vi.fn(async () => [0.1, 0.2, 0.3]),
} as unknown as EmbeddingService;
const coord = {
getMissionStatus: vi.fn(async () => null),
listTasks: vi.fn(async () => []),
getTaskStatus: vi.fn(async () => null),
} as unknown as CoordService;
return {
service: new McpService(brain, memory, embeddings, coord),
brain: brain as unknown as {
conversations: { findAll: ReturnType<typeof vi.fn> };
tasks: {
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
},
memory: memory as unknown as {
insights: { searchByEmbedding: ReturnType<typeof vi.fn> };
},
coord: coord as unknown as {
listTasks: ReturnType<typeof vi.fn>;
},
};
}
function getTool(tools: Map<string, CapturedTool>, name: McpToolName): CapturedTool {
const tool = tools.get(name);
if (!tool) throw new Error(`Missing captured tool ${name}`);
return tool;
}
function makeMemberActor(userId = 'authenticated-user') {
return createMcpActorContext({
userId,
role: 'member',
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
});
}
function makeAdminActor(userId = 'admin-user', tenantId?: string) {
return createMcpActorContext({
userId,
tenantId,
role: 'admin',
scopes: deriveMcpToolScopesForUser({ role: 'admin' }),
});
}
function makePlatformAdminActor(userId = 'platform-admin-user') {
return createMcpActorContext({
userId,
role: 'platform-admin',
scopes: deriveMcpToolScopesForUser({ role: 'platform-admin' }),
});
}
describe('MCP actor identity and tool scope enforcement', () => {
it('derives immutable actor, tenant, channel, correlation, and explicit tool scopes server-side', () => {
const actor = createMcpActorContext({
userId: ' user-authenticated ',
role: 'member',
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
});
expect(actor.userId).toBe('user-authenticated');
expect(actor.tenantId).toBe('user:user-authenticated');
expect(actor.role).toBe('member');
expect(actor.channel).toBe('mcp');
expect(actor.correlationId).toMatch(/[0-9a-f-]{36}/i);
expect(actor.scopes.has(MCP_TOOL_SCOPES.memory_search)).toBe(true);
expect(actor.scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(true);
});
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
const actor = createMcpActorContext({ userId: 'user-authenticated' });
expect(actor.scopes.size).toBe(0);
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
'MCP tool scope denied: memory:insight:read',
);
});
it('fails closed when a tool caller supplies actor or tenant identity fields', () => {
const actor = createMcpActorContext({ userId: 'user-authenticated' });
expect(() =>
assertMcpToolAuthorized(actor, 'memory_search', {
userId: 'victim-user',
query: 'private data',
}),
).toThrow('MCP caller-controlled identity field is forbidden: userId');
expect(() =>
assertMcpToolAuthorized(actor, 'coord_list_tasks', {
tenantId: 'victim-tenant',
projectPath: '/tmp/project',
}),
).toThrow('MCP caller-controlled identity field is forbidden: tenantId');
expect(() =>
assertMcpToolAuthorized(actor, 'brain_create_task', {
title: 'forged org',
organizationId: 'victim-org',
}),
).toThrow('MCP caller-controlled identity field is forbidden: organizationId');
expect(() =>
assertMcpToolAuthorized(actor, 'brain_update_task', {
title: 'forged team',
teamId: 'victim-team',
}),
).toThrow('MCP caller-controlled identity field is forbidden: teamId');
});
it('fails closed when the server-derived actor lacks the required per-tool scope', () => {
const actor = createMcpActorContext({ userId: 'user-authenticated', scopes: [] });
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
'MCP tool scope denied: memory:insight:read',
);
});
it('removes caller-controlled userId from memory schemas and never queries victim memory', async () => {
const { service, memory } = makeService();
const { server, tools } = makeCapturingServer();
const actor = makeMemberActor('authenticated-user');
service.registerTools(server, actor);
const tool = getTool(tools, 'memory_search');
expect(tool.inputSchema.safeParse({ userId: 'victim-user', query: 'anything' }).success).toBe(
false,
);
await expect(tool.handler({ userId: 'victim-user', query: 'anything' })).rejects.toThrow(
'MCP caller-controlled identity field is forbidden: userId',
);
expect(memory.insights.searchByEmbedding).not.toHaveBeenCalled();
await tool.handler({ query: 'only my notes' });
expect(memory.insights.searchByEmbedding).toHaveBeenCalledWith(
'authenticated-user',
[0.1, 0.2, 0.3],
5,
);
});
it('binds conversation listing to the authenticated actor instead of a caller-supplied userId', async () => {
const { service, brain } = makeService();
const { server, tools } = makeCapturingServer();
const actor = makeMemberActor('authenticated-user');
service.registerTools(server, actor);
const tool = getTool(tools, 'brain_list_conversations');
expect(tool.inputSchema.safeParse({ userId: 'victim-user' }).success).toBe(false);
await expect(tool.handler({ userId: 'victim-user' })).rejects.toThrow(
'MCP caller-controlled identity field is forbidden: userId',
);
expect(brain.conversations.findAll).not.toHaveBeenCalled();
await tool.handler({});
expect(brain.conversations.findAll).toHaveBeenCalledWith('authenticated-user');
});
it('scopes brain project, mission, and task reads to the authenticated actor', async () => {
const { service } = makeService({
projects: [
{ id: 'project-owned', ownerId: 'authenticated-user', name: 'owned' },
{ id: 'project-victim', ownerId: 'victim-user', name: 'victim' },
],
missions: [
{ id: 'mission-owned', projectId: 'project-owned' },
{ id: 'mission-victim', userId: 'victim-user', projectId: 'project-victim' },
],
tasks: [
{ id: 'task-owned-project', projectId: 'project-owned', status: 'not-started' },
{ id: 'task-owned-mission', missionId: 'mission-owned', status: 'not-started' },
{ id: 'task-victim-project', projectId: 'project-victim', status: 'not-started' },
{ id: 'task-unowned', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeMemberActor('authenticated-user');
service.registerTools(server, actor);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual([
'task-owned-project',
'task-owned-mission',
]);
});
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
const { service } = makeService({
projects: [
{
id: 'project-tenant-a',
ownerId: 'other-user-a',
teamId: 'tenant-a',
name: 'same tenant',
},
{
id: 'project-tenant-b',
ownerId: 'other-user-b',
teamId: 'tenant-b',
name: 'other tenant',
},
],
missions: [
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, actor);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
});
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
const { service, brain } = makeService({
projects: [
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
],
missions: [
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, actor);
await expect(
getTool(tools, 'brain_create_task').handler({
title: 'unscoped tenant write',
}),
).rejects.toThrow('MCP task scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
await expect(
getTool(tools, 'brain_create_task').handler({
title: 'cross-tenant write',
projectId: 'project-tenant-b',
}),
).rejects.toThrow('MCP task project scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
await expect(
getTool(tools, 'brain_update_task').handler({
id: 'task-tenant-a',
projectId: 'project-tenant-b',
}),
).rejects.toThrow('MCP task project scope denied');
expect(brain.tasks.update).not.toHaveBeenCalled();
const updateResult = await getTool(tools, 'brain_update_task').handler({
id: 'task-tenant-b',
title: 'cross-tenant update',
});
expect(updateResult.content[0]!.text).toBe('Task not found: task-tenant-b');
expect(brain.tasks.update).not.toHaveBeenCalled();
await getTool(tools, 'brain_create_task').handler({
title: 'same-tenant write',
projectId: 'project-tenant-a',
});
expect(brain.tasks.create).toHaveBeenCalledWith(
expect.objectContaining({ projectId: 'project-tenant-a', title: 'same-tenant write' }),
);
});
it('keeps admin-only coordination tools on server-derived paths', async () => {
const { service, coord } = makeService();
const { server, tools } = makeCapturingServer();
const member = makeMemberActor('authenticated-user');
const tenantAdmin = makeAdminActor('admin-user');
const platformAdmin = makePlatformAdminActor('platform-admin-user');
service.registerTools(server, member);
const memberTool = getTool(tools, 'coord_list_tasks');
expect(memberTool.inputSchema.safeParse({ projectPath: '/tmp/victim' }).success).toBe(false);
await expect(memberTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
tools.clear();
service.registerTools(server, tenantAdmin);
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
tools.clear();
service.registerTools(server, platformAdmin);
const platformAdminTool = getTool(tools, 'coord_list_tasks');
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
});
it('does not attach a guessed or stale-scope MCP session to another authenticated context', async () => {
const { service } = makeService();
const owner = makeMemberActor('owner-user');
const attacker = makeMemberActor('attacker-user');
const admin = makeAdminActor('admin-user');
const downgradedAdmin = makeMemberActor('admin-user');
const { sessionId, transport } = service.createSession(owner);
const staleSession = service.createSession(admin);
expect(service.getSession(sessionId, owner)).toBe(transport);
expect(service.getSession(sessionId, attacker)).toBeNull();
expect(service.getSession(sessionId, owner)).toBe(transport);
expect(service.getSession(staleSession.sessionId, downgradedAdmin)).toBeNull();
expect(service.getSession(staleSession.sessionId, admin)).toBe(staleSession.transport);
await service.onModuleDestroy();
});
});

View File

@@ -10,11 +10,216 @@ import { MEMORY } from '../memory/memory.tokens.js';
import { EmbeddingService } from '../memory/embedding.service.js';
import { CoordService } from '../coord/coord.service.js';
export const MCP_CALLER_IDENTITY_FIELDS = [
'actorId',
'actor',
'authenticatedUserId',
'channel',
'organizationId',
'ownerId',
'sessionUserId',
'teamId',
'tenant',
'tenantId',
'user',
'userId',
] as const;
type McpCallerIdentityField = (typeof MCP_CALLER_IDENTITY_FIELDS)[number];
export const MCP_TOOL_SCOPES = {
brain_list_projects: 'brain:project:read',
brain_get_project: 'brain:project:read',
brain_list_tasks: 'brain:task:read',
brain_create_task: 'brain:task:write',
brain_update_task: 'brain:task:write',
brain_list_missions: 'brain:mission:read',
brain_list_conversations: 'brain:conversation:read',
memory_search: 'memory:insight:read',
memory_get_preferences: 'memory:preference:read',
memory_save_preference: 'memory:preference:write',
memory_save_insight: 'memory:insight:write',
coord_mission_status: 'coord:read',
coord_list_tasks: 'coord:read',
coord_task_detail: 'coord:read',
} as const;
export type McpToolName = keyof typeof MCP_TOOL_SCOPES;
export type McpToolScope = (typeof MCP_TOOL_SCOPES)[McpToolName];
export interface McpActorContext {
userId: string;
tenantId: string;
role: string;
channel: 'mcp';
correlationId: string;
scopes: ReadonlySet<McpToolScope>;
}
interface SessionEntry {
server: McpServer;
transport: StreamableHTTPServerTransport;
createdAt: Date;
actor: McpActorContext;
}
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
export function deriveMcpToolScopesForUser(input: {
role?: string | null;
}): ReadonlySet<McpToolScope> {
if (input.role === 'platform-admin' || input.role === 'super-admin') {
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
}
if (input.role === 'admin') {
return new Set(TENANT_ADMIN_MCP_SCOPES);
}
return new Set(MEMBER_MCP_SCOPES);
}
export function createMcpActorContext(input: {
userId: string;
tenantId?: string;
role?: string | null;
scopes?: Iterable<McpToolScope>;
correlationId?: string;
}): McpActorContext {
const userId = input.userId.trim();
if (userId.length === 0) {
throw new Error('MCP authenticated user is required');
}
return {
userId,
tenantId: input.tenantId?.trim() || `user:${userId}`,
role: input.role ?? 'member',
channel: 'mcp',
correlationId: input.correlationId ?? randomUUID(),
scopes: new Set(input.scopes ?? []),
};
}
export function assertNoCallerControlledIdentity(params: unknown): void {
if (params === null || typeof params !== 'object') return;
const keys = new Set(Object.keys(params));
const forbidden = MCP_CALLER_IDENTITY_FIELDS.find((field: McpCallerIdentityField) =>
keys.has(field),
);
if (forbidden) {
throw new Error(`MCP caller-controlled identity field is forbidden: ${forbidden}`);
}
}
export function assertMcpToolAuthorized(
actor: McpActorContext,
toolName: McpToolName,
params: unknown,
): void {
assertNoCallerControlledIdentity(params);
const requiredScope = MCP_TOOL_SCOPES[toolName];
if (!actor.scopes.has(requiredScope)) {
throw new Error(`MCP tool scope denied: ${requiredScope}`);
}
}
function strictObject<T extends z.ZodRawShape>(shape: T): z.ZodObject<T> {
return z.object(shape).strict();
}
type TenantScopedLike = {
tenantId?: string | null;
organizationId?: string | null;
teamId?: string | null;
};
type ProjectLike = TenantScopedLike & { id: string; ownerId?: string | null };
type MissionLike = TenantScopedLike & {
id: string;
projectId?: string | null;
userId?: string | null;
};
type TaskLike = TenantScopedLike & {
projectId?: string | null;
missionId?: string | null;
userId?: string | null;
};
function isGlobalAdminActor(actor: McpActorContext): boolean {
return actor.role === 'platform-admin' || actor.role === 'super-admin';
}
function isTenantAdminActor(actor: McpActorContext): boolean {
return actor.role === 'admin';
}
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
return (
record.tenantId === actor.tenantId ||
record.organizationId === actor.tenantId ||
record.teamId === actor.tenantId
);
}
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
if (isGlobalAdminActor(actor)) return projects;
return projects.filter(
(project) =>
project.ownerId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
);
}
function filterMissionsByDirectActorScope<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): T[] {
if (isGlobalAdminActor(actor)) return missions;
return missions.filter(
(mission) =>
mission.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
);
}
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
if (left.size !== right.size) return false;
for (const scope of left) {
if (!right.has(scope)) return false;
}
return true;
}
function sameActorAuthorization(stored: McpActorContext, current: McpActorContext): boolean {
return (
stored.userId === current.userId &&
stored.tenantId === current.tenantId &&
stored.role === current.role &&
scopesEqual(stored.scopes, current.scopes)
);
}
@Injectable()
@@ -33,13 +238,18 @@ export class McpService implements OnModuleDestroy {
* Creates a new MCP session with its own server + transport pair.
* Returns the transport for use by the controller.
*/
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
createSession(actor: McpActorContext): {
sessionId: string;
transport: StreamableHTTPServerTransport;
} {
const sessionId = randomUUID();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
onsessioninitialized: (id) => {
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
this.logger.log(
`MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
);
},
});
@@ -48,7 +258,7 @@ export class McpService implements OnModuleDestroy {
{ capabilities: { tools: {} } },
);
this.registerTools(server, userId);
this.registerTools(server, actor);
transport.onclose = () => {
this.logger.log(`MCP session closed: ${sessionId}`);
@@ -61,31 +271,126 @@ export class McpService implements OnModuleDestroy {
);
});
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor });
return { sessionId, transport };
}
/**
* Returns the transport for an existing session, or null if not found.
* Returns the transport for an existing session only when it belongs to the
* currently authenticated MCP actor. Guessed or cross-tenant session IDs grant
* no authority.
*/
getSession(sessionId: string): StreamableHTTPServerTransport | null {
return this.sessions.get(sessionId)?.transport ?? null;
getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null {
const entry = this.sessions.get(sessionId);
if (!entry) return null;
if (!sameActorAuthorization(entry.actor, actor)) {
this.logger.warn(
`MCP session actor or scope mismatch: session=${sessionId} actor=${actor.userId} tenant=${actor.tenantId} role=${actor.role}`,
);
return null;
}
return entry.transport;
}
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
}
private async filterMissionsForActor<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return missions;
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
const projectIds = new Set(
filterProjectsForActor(actor, projects).map((project) => project.id),
);
return missions.filter(
(mission) =>
filterMissionsByDirectActorScope(actor, [mission]).length === 1 ||
(typeof mission.projectId === 'string' && projectIds.has(mission.projectId)),
);
}
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
if (!mission) return false;
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
}
private async assertTaskReferencesAuthorized(
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (refs.projectId && !(await this.isProjectAuthorized(actor, refs.projectId))) {
throw new Error('MCP task project scope denied');
}
if (refs.missionId && !(await this.isMissionAuthorized(actor, refs.missionId))) {
throw new Error('MCP task mission scope denied');
}
}
private async assertTaskCreateScopeAuthorized(
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
throw new Error('MCP task scope denied');
}
await this.assertTaskReferencesAuthorized(actor, refs);
}
private async filterTasksForActor<T extends TaskLike>(
actor: McpActorContext,
tasks: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return tasks;
const [projects, missions] = await Promise.all([
this.brain.projects.findAll(),
this.brain.missions.findAll(),
]);
const projectIds = new Set(
filterProjectsForActor(actor, projects as ProjectLike[]).map((project) => project.id),
);
const missionIds = new Set(
(await this.filterMissionsForActor(actor, missions as MissionLike[])).map(
(mission) => mission.id,
),
);
return tasks.filter(
(task) =>
task.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
);
}
/**
* Registers all platform tools on the given McpServer instance.
*/
private registerTools(server: McpServer, _userId: string): void {
registerTools(server: McpServer, actor: McpActorContext): void {
// ─── Brain: Project tools ────────────────────────────────────────────
server.registerTool(
'brain_list_projects',
{
description: 'List all projects in the brain.',
inputSchema: z.object({}),
inputSchema: strictObject({}),
},
async () => {
const projects = await this.brain.projects.findAll();
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_projects', params);
const projects = filterProjectsForActor(
actor,
(await this.brain.projects.findAll()) as ProjectLike[],
);
return {
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
};
@@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy {
'brain_get_project',
{
description: 'Get a project by ID.',
inputSchema: z.object({
inputSchema: strictObject({
id: z.string().describe('Project ID (UUID)'),
}),
},
async ({ id }) => {
const project = await this.brain.projects.findById(id);
async ({ id, ...params }) => {
assertMcpToolAuthorized(actor, 'brain_get_project', params);
const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined;
const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined;
return {
content: [
{
type: 'text' as const,
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
text: authorizedProject
? JSON.stringify(authorizedProject, null, 2)
: `Project not found: ${id}`,
},
],
};
@@ -119,20 +428,23 @@ export class McpService implements OnModuleDestroy {
'brain_list_tasks',
{
description: 'List tasks, optionally filtered by project, mission, or status.',
inputSchema: z.object({
inputSchema: strictObject({
projectId: z.string().optional().describe('Filter by project ID'),
missionId: z.string().optional().describe('Filter by mission ID'),
status: z.string().optional().describe('Filter by status'),
}),
},
async ({ projectId, missionId, status }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_tasks', params);
const { projectId, missionId, status } = params;
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
let tasks;
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
else tasks = await this.brain.tasks.findAll();
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]);
return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] };
},
);
@@ -140,7 +452,7 @@ export class McpService implements OnModuleDestroy {
'brain_create_task',
{
description: 'Create a new task in the brain.',
inputSchema: z.object({
inputSchema: strictObject({
title: z.string().describe('Task title'),
description: z.string().optional().describe('Task description'),
projectId: z.string().optional().describe('Project ID'),
@@ -149,6 +461,8 @@ export class McpService implements OnModuleDestroy {
}),
},
async (params) => {
assertMcpToolAuthorized(actor, 'brain_create_task', params);
await this.assertTaskCreateScopeAuthorized(actor, params);
type Priority = 'low' | 'medium' | 'high' | 'critical';
const task = await this.brain.tasks.create({
...params,
@@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy {
'brain_update_task',
{
description: 'Update an existing task.',
inputSchema: z.object({
inputSchema: strictObject({
id: z.string().describe('Task ID'),
title: z.string().optional(),
description: z.string().optional(),
@@ -171,9 +485,17 @@ export class McpService implements OnModuleDestroy {
.optional()
.describe('not-started, in-progress, blocked, done, cancelled'),
priority: z.string().optional(),
projectId: z.string().optional().describe('Project ID'),
missionId: z.string().optional().describe('Mission ID'),
}),
},
async ({ id, ...updates }) => {
assertMcpToolAuthorized(actor, 'brain_update_task', updates);
const existing = (await this.brain.tasks.findById(id)) as TaskLike | undefined;
if (!existing || (await this.filterTasksForActor(actor, [existing])).length === 0) {
return { content: [{ type: 'text' as const, text: `Task not found: ${id}` }] };
}
await this.assertTaskReferencesAuthorized(actor, updates);
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
type Priority = 'low' | 'medium' | 'high' | 'critical';
const task = await this.brain.tasks.update(id, {
@@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy {
'brain_list_missions',
{
description: 'List all missions, optionally filtered by project.',
inputSchema: z.object({
inputSchema: strictObject({
projectId: z.string().optional().describe('Filter by project ID'),
}),
},
async ({ projectId }) => {
const missions = projectId
? await this.brain.missions.findByProject(projectId)
: await this.brain.missions.findAll();
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_missions', params);
const { projectId } = params;
const missions = await this.filterMissionsForActor(
actor,
(projectId
? await this.brain.missions.findByProject(projectId)
: await this.brain.missions.findAll()) as MissionLike[],
);
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
},
);
@@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy {
server.registerTool(
'brain_list_conversations',
{
description: 'List conversations for a user.',
inputSchema: z.object({
userId: z.string().describe('User ID'),
}),
description: 'List conversations for the authenticated MCP actor.',
inputSchema: strictObject({}),
},
async ({ userId }) => {
const conversations = await this.brain.conversations.findAll(userId);
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
const conversations = await this.brain.conversations.findAll(actor.userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
};
@@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy {
'memory_search',
{
description:
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
inputSchema: z.object({
userId: z.string().describe('User ID to search memory for'),
'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
inputSchema: strictObject({
query: z.string().describe('Natural language search query'),
limit: z.number().optional().describe('Max results (default 5)'),
}),
},
async ({ userId, query, limit }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_search', params);
const { query, limit } = params;
if (!this.embeddings.available) {
return {
content: [
@@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy {
};
}
const embedding = await this.embeddings.embed(query);
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
const results = await this.memory.insights.searchByEmbedding(
actor.userId,
embedding,
limit ?? 5,
);
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
},
);
@@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy {
server.registerTool(
'memory_get_preferences',
{
description: 'Retrieve stored preferences for a user.',
inputSchema: z.object({
userId: z.string().describe('User ID'),
description: 'Retrieve stored preferences for the authenticated MCP actor.',
inputSchema: strictObject({
category: z
.string()
.optional()
.describe('Filter by category: communication, coding, workflow, appearance, general'),
}),
},
async ({ userId, category }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_get_preferences', params);
const { category } = params;
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
const prefs = category
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
: await this.memory.preferences.findByUser(userId);
? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
: await this.memory.preferences.findByUser(actor.userId);
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
},
);
@@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy {
'memory_save_preference',
{
description:
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
inputSchema: z.object({
userId: z.string().describe('User ID'),
'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
inputSchema: strictObject({
key: z.string().describe('Preference key'),
value: z.string().describe('Preference value (JSON string)'),
category: z
@@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy {
.describe('Category: communication, coding, workflow, appearance, general'),
}),
},
async ({ userId, key, value, category }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_save_preference', params);
const { key, value, category } = params;
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
let parsedValue: unknown;
try {
@@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy {
parsedValue = value;
}
const pref = await this.memory.preferences.upsert({
userId,
userId: actor.userId,
key,
value: parsedValue,
category: (category as Cat) ?? 'general',
@@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy {
'memory_save_insight',
{
description:
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
inputSchema: z.object({
userId: z.string().describe('User ID'),
'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
inputSchema: strictObject({
content: z.string().describe('The insight or knowledge to store'),
category: z
.string()
@@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy {
.describe('Category: decision, learning, preference, fact, pattern, general'),
}),
},
async ({ userId, content, category }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_save_insight', params);
const { content, category } = params;
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
const insight = await this.memory.insights.create({
userId,
userId: actor.userId,
content,
embedding,
source: 'agent',
@@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy {
{
description:
'Get the current orchestration mission status including milestones, tasks, and active session.',
inputSchema: z.object({
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
inputSchema: strictObject({}),
},
async ({ projectPath }) => {
const resolvedPath = projectPath ?? process.cwd();
const status = await this.coordService.getMissionStatus(resolvedPath);
async (params) => {
assertMcpToolAuthorized(actor, 'coord_mission_status', params);
const status = await this.coordService.getMissionStatus(process.cwd());
return {
content: [
{
@@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy {
'coord_list_tasks',
{
description: 'List all tasks from the orchestration TASKS.md file.',
inputSchema: z.object({
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
inputSchema: strictObject({}),
},
async ({ projectPath }) => {
const resolvedPath = projectPath ?? process.cwd();
const tasks = await this.coordService.listTasks(resolvedPath);
async (params) => {
assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
const tasks = await this.coordService.listTasks(process.cwd());
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
},
);
@@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy {
'coord_task_detail',
{
description: 'Get detailed status for a specific orchestration task.',
inputSchema: z.object({
inputSchema: strictObject({
taskId: z.string().describe('Task ID (e.g. P2-005)'),
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
},
async ({ taskId, projectPath }) => {
const resolvedPath = projectPath ?? process.cwd();
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
async (params) => {
assertMcpToolAuthorized(actor, 'coord_task_detail', params);
const { taskId } = params;
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
return {
content: [
{

View File

@@ -3,8 +3,10 @@ import {
createMemory,
type Memory,
createMemoryAdapter,
createOperatorMemoryPlugin,
type MemoryAdapter,
type MemoryConfig,
type OperatorMemoryPlugin,
} from '@mosaicstack/memory';
import type { Db } from '@mosaicstack/db';
import type { StorageAdapter } from '@mosaicstack/storage';
@@ -14,6 +16,9 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js';
import { MEMORY } from './memory.tokens.js';
import { MemoryController } from './memory.controller.js';
import { EmbeddingService } from './embedding.service.js';
import { redactSensitiveContent } from '@mosaicstack/log';
export const OPERATOR_MEMORY_PLUGIN = 'OPERATOR_MEMORY_PLUGIN';
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
@@ -38,9 +43,24 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter)
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
},
{
provide: OPERATOR_MEMORY_PLUGIN,
useFactory: (adapter: MemoryAdapter): OperatorMemoryPlugin | null => {
const instanceId = process.env['MOSAIC_OPERATOR_MEMORY_INSTANCE_ID']?.trim();
const namespace = process.env['MOSAIC_OPERATOR_MEMORY_NAMESPACE']?.trim();
if (!instanceId || !namespace) return null;
return createOperatorMemoryPlugin({
adapter,
instanceId,
namespace,
redact: (content) => redactSensitiveContent(content).content,
});
},
inject: [MEMORY_ADAPTER],
},
EmbeddingService,
],
controllers: [MemoryController],
exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService],
exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService],
})
export class MemoryModule {}

View File

@@ -0,0 +1,715 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createDiscordIngressEnvelope,
verifyDiscordIngressEnvelope,
DiscordPlugin,
type DiscordIngressPayload,
parseDiscordInteractionBindings,
resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding,
} from '@mosaicstack/discord-plugin';
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
import { ChatGateway } from '../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
import { DiscordReplayProtector } from './discord-replay-protector.js';
const SERVICE_TOKEN = 'test-service-token';
const ENV_KEYS = [
'DISCORD_SERVICE_TOKEN',
'DISCORD_SERVICE_USER_ID',
'DISCORD_SERVICE_TENANT_ID',
'DISCORD_INTERACTION_BINDINGS',
'DISCORD_ALLOWED_GUILD_IDS',
'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CONFIG_ID',
] as const;
const savedEnv = new Map<string, string | undefined>();
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
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_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': {
role: role === 'admin' ? 'admin' : 'operator',
mosaicUserId: 'mosaic-admin-001',
},
},
},
]);
}
afterEach((): void => {
for (const key of ENV_KEYS) {
const value = savedEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
savedEnv.clear();
});
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
const entries = new Map<string, string>();
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => entries.set(key, value),
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
function discordGateway(role: 'admin' | 'member'): {
gateway: ChatGateway;
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
consumedActions: Array<{ actorId: string; correlationId: string }>;
durable: { getSnapshot: ReturnType<typeof vi.fn> };
audit: { record: ReturnType<typeof vi.fn> };
} {
const authorization = commandAuthorization(role);
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const audit = { record: vi.fn().mockResolvedValue(undefined) };
const runtimeRegistry = new RuntimeProviderService(
{
require: () => ({
capabilities: async () => ({ supported: ['session.terminate'] }),
terminate: async () => undefined,
}),
} as never,
{ record: async () => undefined } as never,
{
consume: async (approvalId, action) => {
consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId });
return authorization.consumeRuntimeTerminationApproval(approvalId, action);
},
},
);
return {
gateway: new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
authorization,
runtimeRegistry,
durable as never,
audit as never,
),
client: { data: { discordService: true }, emit: vi.fn() },
consumedActions,
durable,
audit,
};
}
function ingressEnvelope(
content: string,
messageId: string,
overrides: Partial<DiscordIngressPayload> = {},
): ReturnType<typeof createDiscordIngressEnvelope> {
return createDiscordIngressEnvelope(
createPayload({ content, messageId, ...overrides }),
SERVICE_TOKEN,
);
}
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
return {
correlationId: 'correlation-001',
messageId: 'discord-message-001',
guildId: 'guild-001',
channelId: 'channel-001',
userId: 'user-001',
conversationId: 'Nova:discord:channel-001',
content: 'hello Tess',
...overrides,
};
}
describe('Discord ingress security', () => {
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
const [binding] = parseDiscordInteractionBindings(
JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': 'admin' },
},
]),
);
expect(
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
).toEqual(binding);
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
});
it('binds a differently named configured interaction instance without code changes', () => {
const binding = resolveDiscordInteractionBinding(
[
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
},
],
'guild-001',
'channel-001',
'user-001',
'send',
);
expect(binding?.instanceId).toBe('Nova');
});
it('accepts only the configured Discord service identity', () => {
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
});
it('rejects unauthenticated or tampered service envelopes', () => {
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
expect(
verifyDiscordIngressEnvelope(
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
SERVICE_TOKEN,
),
).toBeNull();
});
it.each([
['guild', { guildId: 'unlisted-guild' }],
['channel', { channelId: 'unlisted-channel' }],
['user', { userId: 'unlisted-user' }],
])(
'rejects an unallowlisted Discord %s',
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
expect(
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
guildIds: ['guild-001'],
channelIds: ['channel-001'],
userIds: ['user-001'],
}),
).toBeNull();
},
);
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
const payload = createPayload({
correlationId: 'correlation-trace-123',
messageId: 'discord-snowflake-987',
});
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
expect(
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
guildIds: ['guild-001'],
channelIds: ['channel-001'],
userIds: ['user-001'],
}),
).toEqual(payload);
});
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
const replayProtector = new DiscordReplayProtector(60_000, 2);
expect(replayProtector.claim('discord-message-001')).toBe(true);
expect(replayProtector.claim('discord-message-001')).toBe(false);
expect(replayProtector.claim('discord-message-002')).toBe(true);
expect(replayProtector.claim('discord-message-003')).toBe(true);
expect(replayProtector.size).toBe(2);
});
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
configureDiscordEnv();
const { gateway, client, consumedActions } = discordGateway('admin');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'approve-message', {
correlationId: 'approval-ingress-correlation',
}),
);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
success: boolean;
};
expect(approval.success).toBe(true);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', {
correlationId: 'stop-ingress-correlation',
}),
);
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
correlationId: 'stop-ingress-correlation',
success: true,
});
expect(consumedActions).toEqual([
{
actorId: 'mosaic-admin-001',
correlationId: expect.stringMatching(/^discord-action:v1:/),
},
]);
});
it('audits a Discord mint-side authorization denial', async () => {
configureDiscordEnv();
const { gateway, client, audit } = discordGateway('member');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'denied-approve'),
);
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001',
success: false,
approvalId: undefined,
expiresAt: undefined,
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
outcome: 'denied',
operation: 'session.terminate',
errorCode: 'policy_denied',
}),
);
});
it('rejects approval when the durable session targets a different logical agent', async () => {
configureDiscordEnv();
const { gateway, client, durable } = discordGateway('admin');
durable.getSnapshot.mockResolvedValueOnce({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'mismatched-agent-approve'),
);
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001',
success: false,
approvalId: 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 () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('member');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'member-approve'),
);
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001',
success: false,
approvalId: undefined,
expiresAt: undefined,
});
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope('/stop forged', 'unpaired-stop'),
);
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
});
it('rejects replaying a Discord-created termination approval', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'replay-approve', {
correlationId: 'replay-approval-correlation',
}),
);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
};
await gateway.handleDiscordStop(
client as never,
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', {
correlationId: 'replay-stop-correlation-one',
}),
);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', {
correlationId: 'replay-stop-correlation-two',
}),
);
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
true,
false,
]);
});
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', () => {
const emitted = 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: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
},
],
});
const internals = plugin as unknown as {
client: { user: { id: string } };
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
handleDiscordMessage(message: unknown): void;
};
internals.client = { user: { id: 'bot-001' } };
internals.socket = { connected: true, emit: emitted };
internals.handleDiscordMessage({
id: 'thread-message',
guildId: 'guild-001',
channelId: 'thread-001',
author: { id: 'user-001', bot: false },
mentions: { has: () => true },
content: '<@bot-001> hello from thread',
channel: { parentId: 'channel-001' },
attachments: new Map(),
});
const [, envelope] = emitted.mock.calls[0] as [
string,
ReturnType<typeof createDiscordIngressEnvelope>,
];
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
});
});

View File

@@ -0,0 +1,40 @@
/**
* Bounded replay cache for Discord's globally unique native message IDs.
* Durable ingress idempotency is added with Tess's canonical inbox/outbox work.
*/
export class DiscordReplayProtector {
private readonly claimedAt = new Map<string, number>();
constructor(
private readonly ttlMs = 15 * 60 * 1000,
private readonly maxEntries = 10_000,
) {}
get size(): number {
return this.claimedAt.size;
}
/** Claims an ID exactly once within its bounded retention window. */
claim(messageId: string, now = Date.now()): boolean {
this.prune(now);
if (this.claimedAt.has(messageId)) return false;
this.claimedAt.set(messageId, now);
this.evictOverflow();
return true;
}
private prune(now: number): void {
for (const [messageId, claimedAt] of this.claimedAt) {
if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId);
}
}
private evictOverflow(): void {
while (this.claimedAt.size > this.maxEntries) {
const oldestMessageId = this.claimedAt.keys().next().value;
if (oldestMessageId === undefined) return;
this.claimedAt.delete(oldestMessageId);
}
}
}

View File

@@ -6,7 +6,7 @@ import {
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
import { PluginService } from './plugin.service.js';
import type { IChannelPlugin } from './plugin.interface.js';
@@ -50,19 +50,58 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
function requiredDiscordAllowlist(name: string): string[] {
const value = process.env[name]
?.split(',')
.map((id: string): string => id.trim())
.filter((id: string): boolean => id.length > 0);
if (!value || value.length === 0) {
throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`);
}
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[] {
const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN'];
const discordGuildId = process.env['DISCORD_GUILD_ID'];
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN'];
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordToken) {
if (!discordServiceToken || !discordServiceUserId) {
throw new Error(
'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured',
);
}
plugins.push(
new DiscordChannelPluginAdapter(
new DiscordPlugin({
token: discordToken,
guildId: discordGuildId,
gatewayUrl: discordGatewayUrl,
serviceToken: discordServiceToken,
messageRateLimitPerMinute: optionalPositiveInteger(
'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE',
),
threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'),
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
interactionBindings: parseDiscordInteractionBindings(
process.env['DISCORD_INTERACTION_BINDINGS'],
),
}),
),
);

View File

@@ -1,9 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { ActorTenantScope } from '../auth/session-scope.js';
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
`mosaic:session:${sessionId}:system:fragments`;
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
`${scope.tenantId}:${scope.userId}:${sessionId}`;
const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) =>
`mosaic:session:${scopedSessionId(sessionId, scope)}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string, scope: ActorTenantScope) =>
`mosaic:session:${scopedSessionId(sessionId, scope)}:system:fragments`;
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
interface OverrideFragment {
@@ -20,9 +24,9 @@ export class SystemOverrideService {
this.handle = createQueue();
}
async set(sessionId: string, override: string): Promise<void> {
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
// Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
const fragments: OverrideFragment[] = existing
? (JSON.parse(existing) as OverrideFragment[])
: [];
@@ -37,11 +41,11 @@ export class SystemOverrideService {
// Store both: fragments array and condensed result
const pipeline = this.handle.redis.pipeline();
pipeline.setex(
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
SYSTEM_OVERRIDE_TTL_SECONDS,
JSON.stringify(fragments),
);
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
await pipeline.exec();
this.logger.debug(
@@ -49,21 +53,21 @@ export class SystemOverrideService {
);
}
async get(sessionId: string): Promise<string | null> {
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
}
async renew(sessionId: string): Promise<void> {
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
await pipeline.exec();
}
async clear(sessionId: string): Promise<void> {
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
SESSION_SYSTEM_KEY(sessionId, scope),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
);
this.logger.debug(`Cleared system override for session ${sessionId}`);
}

View File

@@ -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.
**Phase:** Execution (workstream W1 in planning-complete state)
**Current Workstream:** W1 — Federation v1
**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined)
**Progress:** 0 / 3 declared workstreams complete (more workstreams will be declared as scope is refined)
**Status:** active (continuous since 2026-03-13)
**Last Updated:** 2026-04-19 (manifest authored at the rollup level; install-ux-v2 archived; W1 federation planning landed via PR #468)
**Last Updated:** 2026-07-14 (W3 Native Kanban/SOT canon independently approved under issue #751)
**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)
@@ -67,11 +67,12 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
## Workstreams
| # | 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 |
| 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+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
| # | 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 |
| 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) | P0P3; issue #751; implementation held until canon merge |
| W4+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
### Likely Additional Workstreams (Not Yet Declared)

View File

@@ -175,6 +175,69 @@ Delivery uses five gated milestones: runtime contracts/security; Pi service/stat
---
## Official Channel Plugin Workstream (#756)
### Problem and Objective
The Discord plugin currently couples Discord event handling, gateway bridging, and reply routing in one implementation and activates only on mentions. Mosaic needs an official channel adapter that behaves the same no matter whether the bound logical agent currently runs through Claude, Codex, Pi, OpenCode, or a future harness. The Discord connection and conversation address must remain stable while the gateway changes the runtime provider behind that logical session.
The objective is to make Discord the first implementation of a transport-neutral official channel contract, with explicit authorization and deterministic channel/thread routing that future Matrix, Slack, and other adapters can share.
### Scope
#### In Scope
1. `CHN-001`: Transport-neutral channel adapter, route, message, attachment, authorization-principal, response-target, and health contracts in `@mosaicstack/types`, including trusted per-binding logical-agent configuration selection.
2. `CHN-002`: Stable channel conversation addresses based on logical agent plus channel/thread identity; harness, model, and runtime-provider IDs are forbidden from channel session keys.
3. `DSC-001`: An authorized untagged message in a configured agent-bound channel routes to the agent and receives its response in that channel.
4. `DSC-002`: A bot mention in a configured parent channel creates a Discord thread, or reuses the thread already attached to that same native message; the mentioned turn and subsequent thread turns route and respond in that thread.
5. `DSC-003`: A message already inside an authorized thread inherits authorization from its configured parent and never attempts a nested thread.
6. `DSC-004`: Guild, parent channel, user, pairing, and role authorization remains default-deny before thread creation or gateway dispatch.
7. `DSC-005`: Discord service authentication, HMAC envelope integrity, replay protection, attachments, approvals, response chunking, and correlation behavior remain intact.
8. `DSC-006`: The Discord adapter exposes lifecycle and health behavior through the shared channel contract without importing a harness SDK.
#### Out of Scope
1. The logical-agent lease, fencing epoch, execution grant, checkpoint, or cross-harness takeover implementation tracked by #754/#755.
2. Dynamic Discord authorization administration in the web UI.
3. Multi-guild tenant isolation, DMs, slash commands, voice, reactions, or production bot deployment.
4. Implementing Matrix or Slack adapters in this slice.
### Non-Functional Requirements
1. **Security:** no thread or dispatch side effect occurs until guild, parent channel, user, pairing, role, and bounded per-user/channel rate checks pass; attachment metadata is shape- and size-bounded; credentials never enter source, messages, session keys, or logs.
2. **Portability:** channel contracts and stable conversation IDs contain no Claude, Codex, Pi, OpenCode, model, process, or provider-specific field; each configuration-owned binding selects its trusted logical agent without changing the channel identity.
3. **Reliability:** repeated messages for one channel/thread resolve the same conversation handle; reconnecting the adapter does not require a harness-specific rebinding.
4. **Maintainability:** Discord-specific API translation stays in the Discord package; gateway and future adapters depend on transport-neutral contracts.
5. **Observability:** thread creation or routing failure is reported without message content or credential material.
### Acceptance Criteria
1. `AC-CHN-01`: Contract and behavior tests prove the plugin route contains only logical agent plus channel/thread identity and produces the same stable conversation handle regardless of underlying harness selection.
2. `AC-CHN-02`: A mentioned authorized parent-channel message creates a thread (or reuses its already-attached thread), dispatches to the thread conversation, and targets the response to that thread.
3. `AC-CHN-03`: An untagged authorized parent-channel message dispatches to the parent conversation and targets the response to the parent channel.
4. `AC-CHN-04`: Untagged follow-ups inside an authorized thread dispatch and respond in that same thread without creating a nested thread.
5. `AC-CHN-05`: Unauthorized guilds, channels, users, unpaired users, insufficient roles, and rate-limited senders produce no thread and no gateway dispatch.
6. `AC-CHN-06`: Shared channel contracts are exported from `@mosaicstack/types`, Discord implements the lifecycle/health seam, and no harness SDK is imported by the plugin.
7. `AC-CHN-07`: Focused routing/auth tests, package tests, typecheck, lint, formatting, coverage, independent code/security review, and terminal-green CI pass.
### Constraints, Risks, and Assumptions
- Dependency: Mosaic gateway remains the policy, durable-session, audit, and runtime-provider boundary.
- Constraint: This work must not modify orchestrator-to-Pi migration or #754/#755 lease/fencing files.
- Risk: accepting untagged messages could create noisy or unintended agent input. Mitigation: only explicitly configured channels and paired, role-authorized users are accepted, with bounded per-user/channel message and thread rates.
- Risk: Discord thread creation can fail because of channel permissions, archived state, or API rate limits. Mitigation: fail without dispatching a turn whose response destination cannot be honored, and emit sanitized diagnostics.
- `ASSUMPTION:` Configured channels are dedicated agent interaction surfaces, so authorized untagged human messages are intentional agent input.
- `ASSUMPTION:` Mention in a parent channel selects a public thread; messages already in a thread remain there because Discord has no nested threads.
- `ASSUMPTION:` One Discord bot may serve multiple configuration-owned logical-agent bindings.
- `ASSUMPTION:` Static allowlists and paired-user roles are the authorization administration surface for this slice.
### Testing and Delivery Intent
Use TDD for remote-ingress routing and permission boundaries. Required evidence includes parent-channel mention, untagged parent message, existing-thread follow-up, existing-thread mention, thread reuse, unauthorized side-effect denial, stable harness-neutral conversation identity, adapter health, and regression coverage for signed envelopes and approvals. Deliver through issue #756, a reviewed squash PR to `main`, terminal-green CI, and issue closure.
---
## Architecture
### High-Level System Diagram
@@ -529,7 +592,8 @@ Discord remote control channel. Architecture inspired by OpenClaw (https://githu
- Single-guild binding only (v0.1.0) — prevents data leaks between servers
- Receives Discord messages, dispatches through gateway routing
- Streams agent responses back to Discord (chunked for 2000-char limit)
- Supports mention-based activation, thread management for multi-turn
- Routes authorized untagged messages in-channel; mentions create threads (or reuse the same message's attached thread) for multi-turn topics
- Uses stable logical-agent/channel conversation addresses independent of the active harness/provider
- Bot pairing and permission management (Discord user → Mosaic user mapping)
- DM support for private conversations
@@ -702,10 +766,12 @@ Telegram remote control channel.
### FR-9: Remote Control — Discord
- Discord bot that connects to the gateway
- Mention-based activation in channels
- Discord bot that connects to the gateway through a transport-neutral channel adapter contract
- Authorized messages in configured agent-bound channels work without a mention and respond in-channel
- Mentions in parent channels create threads, or reuse a thread already attached to that same native message, for multi-turn conversations
- Messages already in a thread remain there without requiring repeated mentions
- Stable logical-agent/channel conversation identity survives underlying harness/provider changes
- DM support for private conversations
- Thread creation for multi-turn conversations
- Chunked message delivery (Discord 2000-char limit)
- Bot configuration via web dashboard
- Permission management (which Discord users/roles can interact)
@@ -889,10 +955,13 @@ Telegram remote control channel.
### AC-3: Discord Remote Control
- [ ] Discord bot connects and responds to mentions
- [ ] Messages route through gateway to agent pool
- [ ] Discord bot connects through the harness-neutral channel contract
- [ ] Authorized untagged channel messages route through the gateway and respond in-channel
- [ ] Mentioned parent-channel messages create a thread (or reuse their already-attached thread) and respond there
- [ ] Existing-thread follow-ups stay in the thread without repeated mentions
- [ ] Channel/session identity remains stable while the underlying harness/provider changes
- [ ] Responses stream back to Discord (chunked)
- [ ] Thread creation for multi-turn conversations
- [ ] Unauthorized guilds, channels, users, pairings, and roles create no thread and dispatch no message
### AC-4: Gateway Orchestration
@@ -1091,7 +1160,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.
7. ASSUMPTION: **Discord plugin starts minimal and single-guild only**DM support, mention-based channel activation, thread management, chunked responses. Single guild binding to prevent data leaks between servers. Advanced features (voice, components, slash commands, multi-guild) are post-beta. Rationale: Proven pattern from OpenClaw; ship core interaction first; data isolation is non-negotiable.
7. ASSUMPTION: **Discord plugin starts minimal and single-guild only**explicitly configured agent-bound channels accept authorized untagged messages in-channel, while mentions create threads or reuse a thread already attached to that same native message; responses are chunked. Single guild binding prevents data leaks between servers. DM support, voice, components, slash commands, and multi-guild operation are post-beta. Rationale: Ship the requested core interaction model while preserving default-deny data isolation.
8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel.

51
docs/SITEMAP.md Normal file
View File

@@ -0,0 +1,51 @@
# 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 P0P3 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-001016 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)

View File

@@ -14,10 +14,11 @@
## Workstream Rollup
| id | status | workstream | progress | tasks file | notes |
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning |
| 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 |
| id | status | workstream | progress | tasks file | notes |
| --- | ----------------- | ---------------------- | ---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning |
| 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 |
## Cross-Cutting Tracking

View File

@@ -1,9 +1,9 @@
# Channel Protocol Architecture
**Status:** Draft
**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative
**Authors:** Mosaic Core Team
**Last Updated:** 2026-03-22
**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)
**Last Updated:** 2026-07-14
**Covers:** M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix)
---
@@ -11,93 +11,80 @@
The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels).
The protocol consists of two main contracts:
The implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups:
1. `IChannelAdapter` — the interface each channel driver must implement.
2. `ChannelMessage` — the canonical message format that flows through the system.
1. `OfficialChannelAdapter` — transport lifecycle and connection health.
2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data.
3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address.
4. `ChannelResponseTargetDto` — channel/thread destination for replies.
All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects.
All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter.
---
## M7-001: IChannelAdapter Interface
## M7-001: OfficialChannelAdapter Interface
```typescript
interface IChannelAdapter {
/**
* Stable, lowercase identifier for this channel (e.g. "matrix", "discord").
* Used as a namespace key in registry lookups and log metadata.
*/
interface OfficialChannelAdapter {
/** Stable, lowercase adapter identifier such as "discord" or "matrix". */
readonly name: string;
/**
* Establish a connection to the external channel backend.
* Called once at application startup. Must be idempotent (safe to call
* when already connected).
*/
connect(): Promise<void>;
/**
* 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>;
/** Establish both native-channel and gateway connections. */
start(): Promise<void>;
/** Gracefully close connections and release resources. */
stop(): Promise<void>;
/** Best-effort health; ordinary disconnection is a result, not an exception. */
health(): Promise<{
status: 'connected' | 'degraded' | 'disconnected';
detail?: string;
}>;
}
```
The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned.
### Stable conversation route
```typescript
interface ChannelConversationRouteDto {
bindingId: string;
logicalAgentId: string;
conversationId: string;
channelName: string;
authorizationChannelId: string;
responseTarget: { channelId: string; threadId?: string };
}
```
Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target.
### Typed ingress and egress ports
`ChannelIngressPort` is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same `ChannelIngressDto` before signing. The adapter uses a supplied `ChannelIngressPort` directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core.
`ChannelBindingDto` contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits `ChannelIngressDto` to `ChannelIngressPort.receive()`. It includes the normalized message, `ChannelAuthorizedPrincipalDto`, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port.
Gateway policy and runtime routing produce `ChannelEgressDto`, which `ChannelEgressPort.send()` delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics.
### Adapter Registration
Adapters are registered with the `ChannelRegistry` service at startup. The registry calls `connect()` on each adapter and monitors `health()` on a configurable interval (default: 30 s).
Adapters are registered with the gateway plugin host at startup. The host calls `start()`/`stop()` and may monitor `health()` on a configurable interval. A richer dynamic `ChannelRegistry` remains a compatible future extension of this lifecycle contract.
```
ChannelRegistry
└── register(adapter: IChannelAdapter): void
└── getAdapter(name: string): IChannelAdapter | null
└── listAdapters(): IChannelAdapter[]
└── register(adapter: OfficialChannelAdapter): void
└── getAdapter(name: string): OfficialChannelAdapter | null
└── listAdapters(): OfficialChannelAdapter[]
└── healthAll(): Promise<Record<string, AdapterHealth>>
```
---
## M7-002: ChannelMessage Protocol
## M7-002: ChannelMessageDto Protocol
### Canonical Message Format
```typescript
interface ChannelMessage {
interface ChannelMessageDto {
/**
* Globally unique message ID.
* Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
@@ -110,6 +97,7 @@ interface ChannelMessage {
* The adapter populates this from the inbound message.
* For outbound messages, the caller supplies the target channel.
*/
channelName: string;
channelId: string;
/**
@@ -119,7 +107,7 @@ interface ChannelMessage {
senderId: string;
/** Sender classification. */
senderType: 'user' | 'agent' | 'system';
senderKind: 'user' | 'agent' | 'system';
/**
* Textual content of the message.
@@ -136,7 +124,7 @@ interface ChannelMessage {
* - "image" — binary image; content is empty, see attachments
* - "file" — binary file; content is empty, see attachments
*/
contentType: 'text' | 'markdown' | 'code' | 'image' | 'file';
contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file';
/**
* Arbitrary key-value metadata for channel-specific extension fields.
@@ -144,7 +132,7 @@ interface ChannelMessage {
* Adapters should store channel-native IDs here so round-trip correlation
* is possible without altering the canonical fields.
*/
metadata: Record<string, unknown>;
metadata: Readonly<Record<string, ChannelMetadataValue>>;
/**
* Optional thread or reply-chain identifier.
@@ -163,18 +151,21 @@ interface ChannelMessage {
* Binary or URI-referenced attachments.
* Each attachment carries its MIME type and a URL or base64 payload.
*/
attachments?: ChannelAttachment[];
attachments?: readonly ChannelAttachmentDto[];
/** Wall-clock timestamp when the message was sent/received. */
timestamp: Date;
/** ISO-8601 wall-clock timestamp when the message was sent/received. */
timestamp: string;
}
interface ChannelAttachment {
/** Filename or identifier. */
interface ChannelAttachmentDto {
/** Channel-native attachment identifier. */
id: string;
/** Filename or display name. */
name: string;
/** MIME type (e.g. "image/png", "application/pdf"). */
mimeType: string;
/** MIME type when supplied by the channel. */
mimeType: string | null;
/**
* URL pointing to the attachment, OR a `data:` URI with base64 payload.
@@ -192,23 +183,23 @@ interface ChannelAttachment {
## Channel Translation Reference
The following sections document how each supported channel maps its native message format to and from `ChannelMessage`.
The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`.
### Matrix
| ChannelMessage field | Matrix equivalent |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
| `channelId` | Matrix room ID (`!roomid:homeserver`) |
| `senderId` | Matrix user ID (`@user:homeserver`) |
| `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
| `content` | `event.content.body` |
| `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` |
| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` |
| `attachments` | Populated from `url` in `m.image` / `m.file` events |
| `timestamp` | `new Date(event.origin_server_ts)` |
| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` |
| ChannelMessageDto field | Matrix equivalent |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
| `channelId` | Matrix room ID (`!roomid:homeserver`) |
| `senderId` | Matrix user ID (`@user:homeserver`) |
| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
| `content` | `event.content.body` |
| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` |
| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` |
| `attachments` | Populated from `url` in `m.image` / `m.file` events |
| `timestamp` | `new Date(event.origin_server_ts)` |
| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` |
**Outbound:** Adapter sends `m.room.message` with `msgtype = m.text` (or `m.notice` for system messages). Markdown content is sent with `format = org.matrix.custom.html` and a rendered HTML body.
@@ -216,41 +207,58 @@ The following sections document how each supported channel maps its native messa
### Discord
| ChannelMessage field | Discord equivalent |
| -------------------- | ----------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
| `channelId` | Discord channel ID (snowflake string) |
| `senderId` | Discord user ID (snowflake) |
| `senderType` | `"user"` for human members; `"agent"` for bot messages |
| `content` | `message.content` |
| `contentType` | `"markdown"` (Discord uses a markdown-like syntax natively) |
| `threadId` | `message.thread.id` when the message is inside a thread channel |
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
| `attachments` | `message.attachments` mapped to `ChannelAttachment` |
| `timestamp` | `new Date(message.timestamp)` |
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` |
| ChannelMessageDto field | Discord equivalent |
| ----------------------- | ----------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
| `channelId` | Discord channel ID (snowflake string) |
| `senderId` | Discord user ID (snowflake) |
| `senderKind` | `"user"` for human members; `"agent"` for bot messages |
| `content` | `message.content` |
| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) |
| `threadId` | `message.thread.id` when the message is inside a thread channel |
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
| `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` |
| `timestamp` | `new Date(message.timestamp)` |
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` |
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentKind = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
### Discord routing and thread policy
A configured Discord binding maps `(guildId, parentChannelId)` to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID.
| Inbound location/trigger | Conversation and response target |
| ------------------------------------------ | --------------------------------------------------------------- |
| Authorized untagged parent-channel message | Parent channel; response is sent in-channel |
| Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread |
| Authorized message already in a thread | Existing thread; no repeated mention and no nested thread |
| `/approve` or `/stop <approval>` | Current parent/thread durable session; no new topic is created |
Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target.
### Discord service ingress security
The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads.
---
### Telegram
| ChannelMessage field | Telegram equivalent |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
| `channelId` | Telegram `chat_id` (integer as string) |
| `senderId` | Telegram `from.id` (integer as string) |
| `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages |
| `content` | `message.text` or `message.caption` |
| `contentType` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
| `threadId` | `message.message_thread_id` (for supergroup topics) |
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` |
| `timestamp` | `new Date(message.date * 1000)` |
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` |
| ChannelMessageDto field | Telegram equivalent |
| ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
| `channelId` | Telegram `chat_id` (integer as string) |
| `senderId` | Telegram `from.id` (integer as string) |
| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages |
| `content` | `message.text` or `message.caption` |
| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
| `threadId` | `message.message_thread_id` (for supergroup topics) |
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` |
| `timestamp` | `new Date(message.date * 1000)` |
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` |
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
---
@@ -258,19 +266,19 @@ The following sections document how each supported channel maps its native messa
The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel.
| ChannelMessage field | TUI equivalent |
| -------------------- | ------------------------------------------------------------------ |
| `id` | Generated UUID (TUI has no native message IDs) |
| `channelId` | `"tui:<conversationId>"` — the active conversation ID |
| `senderId` | Authenticated Mosaic `userId` |
| `senderType` | `"user"` for human input; `"agent"` for agent replies |
| `content` | Raw text from stdin / agent output |
| `contentType` | `"text"` for input; `"markdown"` for agent responses |
| `threadId` | Not used (TUI sessions are linear) |
| `replyToId` | Not used |
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
| `timestamp` | `new Date()` at the moment of send |
| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` |
| ChannelMessageDto field | TUI equivalent |
| ----------------------- | ------------------------------------------------------------------ |
| `id` | Generated UUID (TUI has no native message IDs) |
| `channelId` | `"tui:<conversationId>"` — the active conversation ID |
| `senderId` | Authenticated Mosaic `userId` |
| `senderKind` | `"user"` for human input; `"agent"` for agent replies |
| `content` | Raw text from stdin / agent output |
| `contentKind` | `"text"` for input; `"markdown"` for agent responses |
| `threadId` | Not used (TUI sessions are linear) |
| `replyToId` | Not used |
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
| `timestamp` | `new Date()` at the moment of send |
| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` |
**Outbound:** The adapter writes rendered content to stdout. Markdown is rendered via a terminal markdown renderer (e.g. `marked-terminal`). Code blocks are syntax-highlighted when `metadata.colorSupport = true`.
@@ -280,19 +288,19 @@ The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the chan
The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`).
| ChannelMessage field | WebUI equivalent |
| -------------------- | ------------------------------------------------------------ |
| `id` | Generated UUID; echoed back in the WebSocket event |
| `channelId` | `"webui:<conversationId>"` |
| `senderId` | Authenticated Mosaic `userId` |
| `senderType` | `"user"` for browser input; `"agent"` for agent responses |
| `content` | Message text from the input field |
| `contentType` | `"text"` or `"markdown"` |
| `threadId` | Not used (conversation model handles threading) |
| `replyToId` | Message ID the user replied to (UI reply affordance) |
| `attachments` | Files uploaded via the file picker; stored to object storage |
| `timestamp` | `new Date()` at send, or server timestamp from event |
| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` |
| ChannelMessageDto field | WebUI equivalent |
| ----------------------- | ------------------------------------------------------------ |
| `id` | Generated UUID; echoed back in the WebSocket event |
| `channelId` | `"webui:<conversationId>"` |
| `senderId` | Authenticated Mosaic `userId` |
| `senderKind` | `"user"` for browser input; `"agent"` for agent responses |
| `content` | Message text from the input field |
| `contentKind` | `"text"` or `"markdown"` |
| `threadId` | Not used (conversation model handles threading) |
| `replyToId` | Message ID the user replied to (UI reply affordance) |
| `attachments` | Files uploaded via the file picker; stored to object storage |
| `timestamp` | `new Date()` at send, or server timestamp from event |
| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` |
**Outbound:** Adapter emits a `chat:message` Socket.IO event. The WebUI React component receives it and appends to the conversation list. Markdown content is rendered client-side via the existing markdown renderer component.
@@ -300,7 +308,7 @@ The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel prot
## Identity Mapping
`mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts.
Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity.
The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context).
@@ -319,8 +327,8 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
## Error Handling Conventions
- `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s).
- `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.
- `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready.
- `ChannelEgressPort.send()` implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried.
- `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.
@@ -328,7 +336,7 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
## Versioning
The `ChannelMessage` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
The `ChannelMessageDto` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
Current version: **1.0.0**
@@ -469,7 +477,7 @@ A single Mosaic conversation can be accessed simultaneously from multiple surfac
### Real-Time Sync Flow
1. A message arrives on any surface (TUI keystroke, browser send, Matrix event).
2. The surface's adapter normalizes the message to `ChannelMessage` and delivers it to `ConversationService`.
2. The surface's adapter normalizes the message to `ChannelMessageDto` and delivers it to `ConversationService`.
3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`.
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.
@@ -557,7 +565,7 @@ Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessi
- Their `channel_identities` row exists (link not revoked).
- They remain members of the relevant Matrix rooms.
Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) removes the `channel_identities` row and causes `mapIdentity()` to return `null`. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).
Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) removes the `channel_identities` row and causes gateway principal resolution to deny the identity. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).
---
@@ -729,7 +737,7 @@ room_retention_policies
created_at TIMESTAMP
```
The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessage` record unless the Mosaic retention policy also covers it.
The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessageDto` record unless the Mosaic retention policy also covers it.
Default retention values:

View File

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

View File

@@ -0,0 +1,36 @@
# 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-001016 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.

View File

@@ -0,0 +1,64 @@
# Native Kanban/SOT Canon
**Status:** KCR-001016 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 P0P3 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 P0P3 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-001016 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-001016 traceability are recorded in the issue scratchpad and linked review reports.
- Independent re-review returned GO with KCR-001016 closed; implementation remains held until canon merge and the dependency-ordered KBN prerequisites complete.

View File

@@ -0,0 +1,195 @@
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0P3
**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.

View File

@@ -0,0 +1,219 @@
# 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` (1128 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-001016. Mos alone releases waves and serializes integration roots.

View File

@@ -0,0 +1,261 @@
# Native Kanban/SOT P0P3 — 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-001016 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.

View File

@@ -0,0 +1,206 @@
/**
* 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

View File

@@ -0,0 +1,419 @@
/**
* 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'
);
}

View File

@@ -0,0 +1,369 @@
/**
* 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;
}

View File

@@ -0,0 +1,16 @@
{
"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"]
}

389
docs/openapi-tess.yaml Normal file
View File

@@ -0,0 +1,389 @@
openapi: 3.1.0
info: { title: Mosaic Tess Gateway, version: 1.0.0 }
security: [{ sessionAuth: [] }]
paths:
/api/interaction/{agentName}/sessions:
{
get:
{
summary: List authorized runtime sessions,
parameters:
[
{ $ref: '#/components/parameters/agentName' },
{ $ref: '#/components/parameters/provider' },
{ $ref: '#/components/parameters/correlation' },
],
responses: { '200': { description: Sessions } },
},
}
/api/interaction/{agentName}/transitional-capabilities:
{
get:
{
summary: Get transitional capability matrix,
parameters:
[
{ $ref: '#/components/parameters/agentName' },
{ $ref: '#/components/parameters/provider' },
{ $ref: '#/components/parameters/correlation' },
],
responses: { '200': { description: Matrix } },
},
}
/api/interaction/{agentName}/tree:
{
get:
{
summary: Get authorized session tree,
parameters:
[
{ $ref: '#/components/parameters/agentName' },
{ $ref: '#/components/parameters/provider' },
{ $ref: '#/components/parameters/correlation' },
],
responses: { '200': { description: Tree } },
},
}
/api/interaction/{agentName}/sessions/{sessionId}/enroll:
{
post:
{
summary: Enroll a durable session,
parameters:
[
{ $ref: '#/components/parameters/agentName' },
{ $ref: '#/components/parameters/sessionId' },
{ $ref: '#/components/parameters/correlation' },
],
requestBody: { $ref: '#/components/requestBodies/Enroll' },
responses: { '200': { description: Enrolled } },
},
}
/api/interaction/{agentName}/sessions/{sessionId}/attach:
{
post:
{
summary: Attach to a runtime session,
parameters:
[
{ $ref: '#/components/parameters/agentName' },
{ $ref: '#/components/parameters/sessionId' },
{ $ref: '#/components/parameters/correlation' },
],
requestBody: { $ref: '#/components/requestBodies/Attach' },
responses: { '200': { description: Attachment } },
},
}
/api/interaction/{agentName}/sessions/{sessionId}/send:
{
post:
{
summary: Queue a durable provider send,
parameters:
[
{ $ref: '#/components/parameters/agentName' },
{ $ref: '#/components/parameters/sessionId' },
{ $ref: '#/components/parameters/correlation' },
],
requestBody: { $ref: '#/components/requestBodies/Send' },
responses: { '200': { description: Queued } },
},
}
/api/interaction/{agentName}/sessions/{sessionId}/stop:
{
post:
{
summary: Stop a session with approval,
parameters:
[
{ $ref: '#/components/parameters/agentName' },
{ $ref: '#/components/parameters/sessionId' },
{ $ref: '#/components/parameters/correlation' },
],
requestBody: { $ref: '#/components/requestBodies/Stop' },
responses: { '200': { description: Stopped }, '403': { description: Approval denied } },
},
}
/api/interaction/{agentName}/sessions/{sessionId}/recover:
{
post:
{
summary: Recover interrupted durable work,
parameters:
[
{ $ref: '#/components/parameters/agentName' },
{ $ref: '#/components/parameters/sessionId' },
{ $ref: '#/components/parameters/correlation' },
],
responses: { '200': { description: Recovered } },
},
}
/api/coord/mos/handoff:
{
post:
{
summary: Submit Mos handoff,
parameters: [{ $ref: '#/components/parameters/correlation' }],
requestBody: { $ref: '#/components/requestBodies/Handoff' },
responses: { '200': { description: Receipt } },
},
}
/api/coord/mos/{handoffId}/observe:
{
get:
{
summary: Observe Mos handoff,
parameters:
[
{ $ref: '#/components/parameters/handoffId' },
{ $ref: '#/components/parameters/correlation' },
],
responses: { '200': { description: Observation } },
},
}
/api/coord/mos/{handoffId}/result:
{
get:
{
summary: Get Mos handoff result,
parameters:
[
{ $ref: '#/components/parameters/handoffId' },
{ $ref: '#/components/parameters/correlation' },
],
responses: { '200': { description: Result } },
},
}
/api/interaction/{agentName}/sessions/{sessionId}/stream:
{
get:
{
summary: Stream runtime events,
parameters:
[
{ $ref: '#/components/parameters/agentName' },
{ $ref: '#/components/parameters/sessionId' },
{ $ref: '#/components/parameters/correlation' },
],
responses:
{
'200':
{
description: Event stream,
content: { text/event-stream: { schema: { type: string } } },
},
},
},
}
/api/memory/preferences:
{
get: { summary: List preferences, responses: { '200': { description: Preferences } } },
post:
{
summary: Upsert preference,
requestBody: { $ref: '#/components/requestBodies/Preference' },
responses: { '200': { description: Preference } },
},
}
/api/memory/preferences/{key}:
{
get:
{
summary: Get preference,
parameters: [{ $ref: '#/components/parameters/key' }],
responses: { '200': { description: Preference } },
},
delete:
{
summary: Delete preference,
parameters: [{ $ref: '#/components/parameters/key' }],
responses: { '204': { description: Deleted } },
},
}
/api/memory/insights:
{
get: { summary: List insights, responses: { '200': { description: Insights } } },
post:
{
summary: Create insight,
requestBody: { $ref: '#/components/requestBodies/Insight' },
responses: { '200': { description: Insight } },
},
}
/api/memory/insights/{id}:
{
get:
{
summary: Get insight,
parameters: [{ $ref: '#/components/parameters/id' }],
responses: { '200': { description: Insight } },
},
delete:
{
summary: Delete insight,
parameters: [{ $ref: '#/components/parameters/id' }],
responses: { '204': { description: Deleted } },
},
}
/api/memory/search:
{
post:
{
summary: Search memory,
requestBody: { $ref: '#/components/requestBodies/Search' },
responses: { '200': { description: Search results } },
},
}
components:
securitySchemes: { sessionAuth: { type: http, scheme: bearer } }
parameters:
agentName: { name: agentName, in: path, required: true, schema: { type: string } }
sessionId: { name: sessionId, in: path, required: true, schema: { type: string } }
provider: { name: provider, in: query, required: true, schema: { type: string } }
correlation: { name: X-Correlation-Id, in: header, required: true, schema: { type: string } }
key: { name: key, in: path, required: true, schema: { type: string } }
id: { name: id, in: path, required: true, schema: { type: string } }
requestBodies:
Enroll:
{
required: true,
content:
{
application/json:
{
schema:
{
type: object,
required: [providerId, runtimeSessionId],
properties:
{ providerId: { type: string }, runtimeSessionId: { type: string } },
},
},
},
}
Handoff:
{
required: true,
content:
{
application/json:
{
schema:
{
type: object,
required: [idempotencyKey, summary],
properties:
{
idempotencyKey: { type: string },
summary: { type: string },
context: { type: string },
missionId: { type: string },
},
},
},
},
}
Attach:
{
content:
{
application/json: { schema: { type: object, properties: { mode: { enum: [read] } } } },
},
}
Send:
{
required: true,
content:
{
application/json:
{
schema:
{
type: object,
required: [content, idempotencyKey],
properties: { content: { type: string }, idempotencyKey: { type: string } },
},
},
},
}
Stop:
{
required: true,
content:
{
application/json:
{
schema:
{
type: object,
required: [approvalRef],
properties: { approvalRef: { type: string } },
},
},
},
}
Preference:
{
required: true,
content:
{
application/json:
{
schema:
{
type: object,
required: [key, value],
properties:
{
key: { type: string },
value: {},
category: { type: string },
source: { type: string },
},
},
},
},
}
Insight:
{
required: true,
content:
{
application/json:
{
schema:
{
type: object,
required: [content],
properties:
{
content: { type: string },
source: { type: string },
category: { type: string },
metadata: { type: object },
},
},
},
},
}
Search:
{
required: true,
content:
{
application/json:
{
schema:
{
type: object,
required: [query],
properties:
{
query: { type: string },
limit: { type: integer },
maxDistance: { type: number },
},
},
},
},
}

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
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-001016 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.

View File

@@ -0,0 +1,357 @@
# 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 tasks 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 coder3s persistence boundary. If coder3 implements the port instead, KBN-200s 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-001s 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-300s 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 Coordinators 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.**

View File

@@ -0,0 +1,38 @@
# #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 | D1D7 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.

View File

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

View File

@@ -0,0 +1,368 @@
# 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 P0P3 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
P0P3 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.

View File

@@ -0,0 +1,43 @@
# #747 — De-hardcode orchestrator and interaction agent names
## Objective
Replace branded Mos/Tess symbols, filenames, DI tokens, and error prose with role-neutral orchestrator/interaction vocabulary without changing env-driven runtime identity behavior. Add optional roster `alias` and `provider` fields and show aliases in `mosaic fleet ps` with name fallback.
## Scope and constraints
- Requirements: `/home/hermes/agent-work/reviews/747-wsa-dehardcode-brief.md`.
- Branch: `feat/747-dehardcode-orchestrator-interaction-names` from `main` at `e72388b2`.
- Keep `MOSAIC_AGENT_NAME` and `MOSAIC_ORCHESTRATOR_AGENT_NAME` unchanged.
- Sample/test data may retain operator display names.
- No behavior change beyond optional roster metadata and alias display.
- Budget: no explicit cap; conservative mechanical-rename scope only.
- TDD: optional and skipped because this is a mechanical rename with existing focused coverage; add focused alias/schema regression coverage before completion.
## Plan
1. Rename coordination and durable-session files and symbols using canonical vocabulary.
2. Scrub branded symbol names and error prose in the assigned source trees while preserving allowed sample data.
3. Extend roster schema with optional `alias` and `provider`; update fleet roster typing/rendering and focused tests.
4. Run grep-clean verification, build, typecheck, lint/format, focused coord/durable-session/fleet tests, and roster validation.
5. Commit, queue-guard, push, open a Gitea PR closing #747, and report to the coordinator.
## Progress
- 2026-07-13: Task resumed from coordinator brief; repository clean at `e72388b2`.
- Renamed coordination and durable-session files, exports, gateway DI symbols, DTOs, services, repositories, and tests.
- Replaced branded authority/error prose while preserving the existing `/api/coord/mos` compatibility route and env-variable identity inputs.
- Added optional roster `alias`/`provider` support and alias-first `fleet ps` display with canonical-name fallback.
## Verification
- `pnpm typecheck`: passed (42 tasks).
- `pnpm build`: passed (23 tasks).
- `pnpm lint`: passed (23 tasks).
- `pnpm format:check`: passed.
- Coordination tests: 7 passed.
- Agent durable-session/runtime tests: 23 passed.
- Gateway coordination/durable-session/integration tests: 20 passed.
- Full `fleet.spec.ts`: 192 passed, including alias/provider parsing and alias display.
- JSON Schema 2020 validation: legacy minimal roster and extended alias/provider roster passed; `alias` and `provider` remain absent from `required`.
- Grep verification: no branded symbol/type/file/DI names or error prose remain in assigned source trees; one allowed `Tess Owner` test-data display name remains.

View File

@@ -0,0 +1,152 @@
# Issue #751 — Native Kanban/SOT canonical publication
## Objective
Publish the owner-ratified P0P3 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-001016 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-001016 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.

View File

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

View File

@@ -56,3 +56,11 @@
**Final focused review:** PASS. Deterministic audit validated all task repository roots with zero missing paths; no planning placeholders remained; security prerequisites still gate Tess exposure; observability traceability is explicit.
**Current gate:** planning PR must merge to `main` with terminal-green CI before any source-code worker starts.
## 2026-07-13 — M3 cross-surface delivery
**Branch:** `feat/tess-m3-integration` from `main` at `84d884b9`.
**Delivered:** Stable Discord `conversationId` enrollment after a visible provider/runtime session is known; idempotent provider-session rebinding that preserves agent/tenant/owner scope; Discord approval/stop target resolution through the durable snapshot; SSE runtime streaming after CLI attach; denial/audit parity including provider authorization denials and HTTP 403 approval-denial mapping.
**Evidence:** Gateway targeted suite: 37 tests passed; Mosaic CLI interaction test passed; agent durable-session test passed; gateway and CLI typechecks passed; changed-file format and whitespace checks passed. Codex security review found no confident vulnerability. Code review identified a Fastify exception-response mismatch and two UX/acknowledgement issues; all were corrected before the final validation run.

View File

@@ -0,0 +1,38 @@
# TESS-M1-002 — Provider Registry
- **Issue:** #707
- **Branch:** `feat/tess-provider-registry`
- **Objective:** Build the runtime provider registry/service boundary that derives immutable actor/tenant/channel/correlation scope server-side, fail-closes unsupported and destructive runtime operations, binds termination approval to the exact structured action, and emits correlation-safe audit events.
## Plan
1. Add a provider-agnostic registry in `@mosaicstack/agent` over the merged `AgentRuntimeProvider` contract.
2. Write abuse-case tests before implementation for duplicate/unknown providers, immutable server-derived scope, capability denial, approval mismatch/absence, and audit failure.
3. Implement the Gateway service that converts only authenticated `ActorTenantScope` plus trusted ingress metadata into a frozen `RuntimeScope`, gates capabilities and terminate approval, and records metadata-only audits.
4. Register the service in `AgentModule`, then run focused, baseline, cold-cache, and independent-review gates.
## Security Invariants
- Caller-supplied actor/tenant identity never reaches runtime providers.
- Provider capability absence and approval/audit failure deny before side effects.
- Termination approval is verified against provider, session, actor, tenant, channel, and correlation context.
- Audit events retain correlation and authority metadata but never message content or approval material.
## Progress
- 2026-07-12: Created fresh worktree from `origin/main` at `119f64e6`; source and Tess planning/security documentation reviewed.
- 2026-07-12: Security TDD added registry and gateway abuse tests before implementation.
- 2026-07-12: Implemented `AgentRuntimeProviderRegistry` and gateway `RuntimeProviderService`; registered both in `AgentModule` and documented the internal boundary.
- 2026-07-12: Independent review found two audit correctness issues. Remediated completion-audit failure handling and provider execution failures: pre-invocation denials are audited as `denied`; post-invocation errors as `failed`; completion audit failure does not misreport a completed effect as retryable.
- 2026-07-12: Final independent security review: no findings. Final code review had one false positive: `@mosaicstack/types` is already declared in `packages/agent/package.json`.
## Tests
- TDD red: `pnpm --filter @mosaicstack/gateway test -- runtime-provider-registry.service.test.ts` failed before both audit remediations, as expected.
- Focused: package registry 2 tests and gateway security boundary 7 tests pass.
- Cold-cache: removed this worktree's `node_modules`, then `pnpm install --offline --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` passed.
- Cold-cache baseline: `TURBO_FORCE=true pnpm typecheck` — 42/42 tasks passed; `TURBO_FORCE=true pnpm lint` — 23/23 tasks passed; `TURBO_FORCE=true pnpm format:check` passed; `TURBO_FORCE=true pnpm test` — 42/42 tasks passed (gateway 548 tests passed, 11 intentionally skipped).
## Risks / Blockers
- The canonical durable approval implementation is currently command-specific. This card introduces a fail-closed runtime approval verifier boundary so a runtime provider cannot terminate until its exact-action verifier is wired; later provider implementations cannot bypass it.

View File

@@ -0,0 +1,75 @@
# TESS-M1-003 — Fleet/tmux Runtime Provider
- **Task:** `TESS-M1-003`
- **Issue:** `#707`
- **Branch:** `feat/tess-fleet-provider`
- **PR target:** `main`
- **Budget:** 30K estimate from `docs/tess/TASKS.md`; work remains scoped to `packages/mosaic`, `packages/agent`, and Tess architecture/scratchpad documentation.
## Objective
Implement `TESS-FLT-001` as a tmux/fleet `AgentRuntimeProvider` on the M1 registry contract. Operations must use roster-bound, exact tmux targets; fail closed on missing or mismatched peer identity; allow read-only attach only; use exact-target message delivery and termination; and expose no arbitrary shell, socket, or fuzzy session targeting.
## Requirements and Security Invariants
- `TESS-FLT-001`: fleet roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, controlled termination/recovery.
- `TESS-ARP-001` / `TESS-TRN-001`: conform to the runtime provider contract and advertise only implemented capabilities.
- TM-10: exact target/socket binding and peer identity verification; wrong socket, target, or identity must refuse delivery/attach.
- Gateway supplies immutable actor/tenant/channel/correlation scope and consumes durable termination approvals before provider invocation.
- `control` attach is denied. A provider attach is a scoped read-only logical handle; it never opens a server-side interactive terminal or exposes a raw tmux target.
- M2 will make durable attachment/session state available. This M1 provider does not claim durable attachment handles or durable message idempotency.
## Plan
1. Add security TDD cases first for fuzzy/unrostered targets, incorrect socket/identity, control attach, attachment scope replay, and termination exact targeting.
2. Add Mosaic fleet primitives for exact target validation and identity probing from the roster/socket.
3. Implement and export the fleet/tmux provider in `@mosaicstack/agent`, using only those primitives and a command-runner seam.
4. Update Tess architecture docs and this evidence log.
5. Run focused tests, independent code/security reviews, cold-cache forced gates, then create a PR with `Refs #707`.
## Branch/Base Note
The orchestrator corrected the initial brief: `feat/tess-interaction-agent` is a stale planning branch. This branch was correctly created from `origin/main` at `e92186d7` (including M1-002) and will open a clean PR to `main` with `Refs #707`.
## Progress
- [x] Read PRD, Tess architecture, threat model, runtime contract, registry, fleet command primitives, task record, and issue #707.
- [x] Created clean worktree from `origin/main` at `e92186d7`.
- [x] Security TDD tests written before implementation; initially failed because the transport/provider modules did not exist.
- [x] Fleet transport and capability-limited provider implemented; read/list/attach and direct Tess write/control default to deny pending scope-aware authority adapters.
- [x] Focused typecheck, lint, formatting, and abuse tests passed (transport: 7; provider: 14).
- [x] Cold-cache forced workspace gates passed after reinstall; final workspace gates also passed.
- [x] Independent Codex code and security reviews passed with no findings.
## Verification Evidence
- `pnpm --filter @mosaicstack/mosaic typecheck` — pass.
- `pnpm --filter @mosaicstack/agent typecheck` — pass.
- `pnpm --filter @mosaicstack/mosaic lint` — pass.
- `pnpm --filter @mosaicstack/agent lint` — pass.
- `pnpm --filter @mosaicstack/mosaic test -- src/fleet/tmux-runtime-transport.test.ts` — 7 passed.
- `pnpm --filter @mosaicstack/agent test -- src/tmux-fleet-runtime-provider.test.ts` — 14 passed.
- The worktree dependency install must use `--store-dir /home/jarvis/.local/share/pnpm/store` because machine pnpm config points to an unreadable root-owned store. This is a local tool configuration issue, not an application workaround.
## Documentation Checklist
- [x] Canonical PRD and Tess architecture are current for this internal provider; no HTTP/API endpoint changed.
- [x] `docs/tess/ARCHITECTURE.md` documents the internal fleet target/identity, read-only attach, and Mos authority boundary.
- [x] No user/admin/API sitemap updates are applicable because no user-facing or HTTP API surface was introduced.
## Acceptance Criteria to Evidence
| Acceptance criterion | Evidence target |
| --- | --- |
| Only roster-bound exact targets are operated | Provider abuse tests prove unknown/prefix targets yield typed denial and runner is untouched. |
| Socket and peer runtime identity are exact | Provider abuse tests prove wrong socket/no pane/runtime drift deny before send/attach/terminate. |
| Message sends are capability-safe and exact | Tests assert the maintained sender receives only the configured socket and exact roster session. |
| Fleet reads cannot cross an authority boundary | Tests prove list and read attach default-deny without a scope-aware read authority; per-target authority filtering is enforced. |
| Attach cannot grant control or replay across scope | Tests deny `control`; attachment handles are random, scoped, short-lived, single-use for detach, and pruned after expiry. |
| Termination is exact and caller cannot select arbitrary target | Tests assert roster/identity validation precedes exact `tmux kill-session -t =<agent>`. Gateway tests from M1-002 cover approval consumption. |
| Documentation describes the boundary | `docs/tess/ARCHITECTURE.md` documents fleet capability, scope, and non-goals. |
## Risks / Decisions
- Runtime process identity can only be verified from the declared fleet roster and exact tmux pane command in M1. The tmux server itself is a trusted local transport boundary; stronger authenticated peer attestations are deferred to the Matrix/native provider.
- The current roster schema does not encode per-agent tenant/owner. Scope-aware read/write authority adapters remain the integration point for gateway/Mos ownership policy; provider scope is bound to logical attachment handles to prevent replay.

View File

@@ -0,0 +1,9 @@
# TESS-M1-OBS-001 Scratchpad
- Branch: `feat/tess-observability-terra` (the requested name is checked out by an abandoned worktree; orchestrator approved this clean branch).
- Base: `origin/main` at `e92186d7`.
- Scope: correlation propagation; metadata-only structured runtime/provider/tool audit; health/readiness; safe effective-policy status.
- Security invariant: audit and status data use an allowlist; no message bodies, credentials, approval references, tool arguments, or tool output.
- TDD: `packages/log/src/runtime-audit.test.ts` and `apps/gateway/src/health/health.controller.test.ts` failed before implementation and now pass.
- Verification: full `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `pnpm test` passed after implementation (2026-07-12).
- Review: corrected audit sanitizer findings by hashing every resource ID. Durable audit persistence remains fail-closed by design: the pre-existing M1 provider-boundary suite requires it to prevent an unaudited side effect.

View File

@@ -0,0 +1,27 @@
# TESS-M1-SEC-001 — Command authorization and exact-action approval
- Issue/milestone: #707 / M1
- Branch: `fix/tess-command-authz`
- Requirement: `TESS-SEC-002`, with approval binding controls from `TESS-SEC-007`
- Scope: `apps/gateway` only, plus required in-repo security/developer documentation.
## Plan
1. Locate the gateway command executor, command metadata, authorization context, and existing test conventions.
2. Write abuse/authz tests before production changes. Expected red cases: non-admin blocked from admin/system command; forged caller scope cannot authorize; privileged/destructive action requires durable exact-action approval; expired/replayed/mutated approvals deny.
3. Implement server-derived role/scope enforcement and durable approval validation/consumption with audit results.
4. Run focused security tests, then repository baseline gates: typecheck, lint, format-check, test.
5. Run independent security/code review, commit, queue-guard, push, and open the PR to `main` through the stated Gitea API fallback. Stop after PR creation.
## Assumptions
- The existing gateway persistence interface is the available durable approval boundary. If no persistence abstraction exists, a minimal injectable repository interface will be introduced rather than an in-memory approval implementation, because TESS-SEC-002/007 require durable enforcement.
- “Exact action” is a canonical digest over structured command identity and normalized arguments; role/scope checks always use authenticated server context, not client-declared claims.
## TDD evidence
- Pending: abuse/authz test written and observed red before implementation.
## Verification evidence
- Pending.

View File

@@ -0,0 +1,35 @@
# Scratchpad — TESS-M1-SEC-004 Discord ingress
- **Task / issue:** TESS-M1-SEC-004 / #707
- **Branch:** `fix/tess-discord-ingress` from `origin/main` at `59e49cfd`
- **Objective:** Authenticate the Discord plugin service at gateway ingress; enforce explicit guild/channel/user allowlists; attach Discord message and generated correlation IDs; reject replayed native message IDs.
- **Scope:** `plugins/discord`, `apps/gateway`, and existing Discord admin/developer protocol docs.
- **Budget:** Task estimate 28K; no explicit hard cap supplied.
- **Assumptions:** The Discord plugin and gateway share an injected high-entropy `DISCORD_SERVICE_TOKEN`; a configured Discord plugin fails closed without it. Allowlist configuration is comma-separated Discord snowflakes. Discord native message ID is the replay key, with bounded in-memory retention pending the M2 durable inbox/idempotency work.
## Plan
1. Add failing tests covering ingress service authentication/signing, unlisted guild/channel/user rejection, correlation propagation, and replay rejection.
2. Implement the signed Discord ingress envelope and allowlist validation in the plugin.
3. Authenticate and validate the envelope at the gateway boundary, then enforce bounded replay protection before agent dispatch.
4. Document the service-token and allowlist operations; run focused and baseline gates; obtain independent review.
## Progress
- 2026-07-12: Intake complete; PRD TESS-SEC-005, architecture, and threat model reviewed.
- Added service-token Socket.IO authentication, HMAC-signed Discord envelopes, default-deny guild/channel/user allowlists, correlated message metadata, bounded replay rejection, and fail-fast configuration checks.
- Code and security reviews completed. Code review findings on service persistence ownership, package-boundary tests, disconnected ingress observability, and chat payload validation were remediated; final independent code review approved.
## Risks / blockers
- Existing `main` has known unrelated Prettier debt; only changed files will be held format-clean. Durable replay persistence is intentionally out of scope for this M1 prerequisite and belongs to TESS-M2 durable inbox/idempotency work.
## Verification evidence
- Focused ingress suite: `pnpm --filter @mosaicstack/gateway test -- discord-ingress.security.spec.ts` — 7 passed.
- Gateway suite: `pnpm --filter @mosaicstack/gateway test` — 513 passed, 11 skipped.
- Plugin suite: `pnpm --filter @mosaicstack/discord-plugin test` — no tests, passed by configured `--passWithNoTests`.
- `pnpm typecheck` — passed.
- `pnpm lint` — passed.
- `pnpm format:check` — fails only on the known pre-existing Tess documentation debt listed in the task dispatch; changed files pass targeted Prettier verification.
- Codex security review — no findings; final Codex code review — approved.

View File

@@ -0,0 +1,40 @@
# TESS-M2-001 — Pi Interaction Service
## Scope
- Add a generic rostered/systemd Pi operator-interaction service in
`packages/mosaic/framework`.
- Pin the service to `openai/gpt-5.6-sol`, high reasoning, and the
`operator-interaction` tool policy.
- Keep identity as provisioning data; the product name appears only in the
committed example roster.
## Security and Configuration Invariants
1. The chosen display/roster name is supplied as data and must exactly match the
generic systemd instance.
2. The service fails before launch if runtime, model, reasoning, or tool policy
differs from the pinned policy.
3. Effective-policy output includes only name, runtime, model, reasoning, and
tool policy; it does not inspect or output credential variables.
4. The default example is replaceable without a source change; the TDD suite
provisions `Nova` from the same profile.
## Evidence
- `src/fleet/tess-service-profile.test.ts` proves a `Nova` provisioning path,
roster parser/env serialization, effective-policy output, fail-fast drift
rejection, and absence of the product name from generic source/profile.
- `test-fleet-units.sh` validates the generic interaction systemd unit requires
per-agent config and invokes fail-fast startup validation.
- `test-start-agent-session.sh` proves the tool-policy value is exported into
the Pi pane; `compose-contract.spec.ts` proves it becomes an explicit
runtime contract block.
- Fresh-worktree dependency install plus root `pnpm typecheck`, `pnpm lint`,
`pnpm format:check`, and `pnpm test` passed; package/full fleet suites passed.
- Independent Codex code and security reviews passed with no remaining findings.
## Delivery Notes
- Branch starts from fresh `origin/main` at `86a50138`.
- PR targets `main` and references issue `#708`.

View File

@@ -0,0 +1,63 @@
# TESS-M2-002 — Durable Tess State
- **Issue:** #708
- **Task:** `TESS-M2-002` / `TESS-STA-001`, `TESS-SEC-007..008`
- **Branch:** `feat/tess-durable-state`
- **Base:** fresh `origin/main` at `e3b5113be21e51d015fa1ae54572929b2a4acd9f`
- **Budget assumption:** 38K task estimate; no explicit cap. Use focused TDD plus workspace validation.
## Objective
Persist a Tess session's immutable identity, inbox/outbox idempotency state, checkpoints,
handoffs, and approval bindings so a new service instance can recover it after a process
restart or context compaction without replaying a completed message or applied side effect.
## Plan
1. Write recovery/idempotency tests first in `packages/agent/src/tess-durable-session.test.ts`.
2. Add transport-neutral durable-state contracts/state machine in `packages/agent`.
3. Add canonical PostgreSQL schema/migration and a gateway Drizzle repository adapter.
4. Wire gateway service/module and reuse `tess:command-approval:*` durable approval semantics
for exact, actor/tenant/action-bound approval consumption.
5. Test PGlite restart recovery with separate service instances sharing the same durable DB.
6. Document the recovery/compaction operation and update Tess architecture evidence.
7. Run focused, cold-cache, workspace, migration, review, commit, push, and open PR to `main`.
## Required Evidence
| Requirement | Primary evidence |
| --- | --- |
| Restart recovery | Test creates a second coordinator over unchanged durable store after simulated process death. |
| No duplicate side effects | Duplicate ingress and post-restart dispatch assert one handler/effect invocation. |
| Compaction survival | Checkpoint/handoff/reconstructed state preserve the same session identity and pending records. |
| Durable approvals | Existing `tess:command-approval` record is consumed only once and survives a new authorization service instance. |
| Handoff | Stored handoff is portable and reconstructed without live process state. |
## Progress
- Intake complete: PRD AC-TESS-06, threat TM-07/TM-08, and verification matrix reviewed.
- Affected surfaces: `packages/agent`, `apps/gateway`, `packages/db`; auth/authorization and DB migration tests required.
- TDD is required (security authorization and critical state mutation).
## Risks
- An external provider action cannot be atomically committed with the database. The outbox
gives the receiver a stable idempotency key; generic recovery never replays an ambiguous
`processing` effect, and completed effects are never redispatched.
- PostgreSQL is canonical; PGlite is the local/restart test implementation.
## Verification
- TDD red: `pnpm --filter @mosaicstack/agent test src/tess-durable-session.test.ts`
initially failed because the durable-state module did not exist.
- Focused green: 7 agent state-machine tests; 6 PGlite repository tests (including
close/reopen recovery and encrypted-at-rest redaction); 5 durable-approval tests; DB migration tests.
- Full cold-cache green: `pnpm turbo run typecheck lint test --force` completed
88 tasks with zero cache hits; `pnpm format:check` and `git diff --check` passed.
- Fresh worktree dependency install passed with
`pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store`.
The default pnpm store path was inaccessible to this harness, so the explicit
user-owned store path was required.
- Codex review identified plaintext durable payload risk; resolved by AES-256-GCM sealing
after redaction, with an at-rest ciphertext assertion in the PGlite suite.
- Pending final clean review, commit, and PR.

View File

@@ -0,0 +1,46 @@
# TESS-M4-001 — Mos Coordination
- **Issue/task:** #710 / TESS-M4-001
- **Branch/base:** `feat/tess-mos-coordination` rebased onto `origin/main` `f1c6b37b`
- **Budget assumption:** task estimate 25K; design-first and TDD, with package contract plus gateway boundary only.
## Objective
Implement a transport-neutral coordination contract allowing a configured interaction agent to hand off Mos-owned work, observe activity, and receive results while preventing it from gaining coding/general orchestration authority.
## Plan
1. Document the contract and enforcement-point sketch; request Mos's decision on the initial concrete transport.
2. Add `@mosaicstack/coord` typed handoff/observe/result contracts and denial errors.
3. Add a gateway service which derives actor/tenant/requester identity from trusted context/configuration and validates authority.
4. Add contract and gateway boundary tests for configurable identities, self-delegation, target drift, and cross-tenant read denial.
5. Run focused, cold-cache, baseline tests; independent review; PR lifecycle.
## 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.
## Progress checkpoint — 2026-07-13
- Implemented `InteractionCoordinationPort` 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.
- 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`.
## Verification
- `pnpm --filter @mosaicstack/coord test` — PASS (16 tests after authority/idempotency remediation).
- `pnpm --filter @mosaicstack/coord build` — PASS.
- `pnpm --filter @mosaicstack/gateway test -- mos-coordination.service.test.ts` — PASS (7 tests after authority/idempotency remediation).
- Standalone gateway typecheck initially reported missing built workspace packages after fresh worktree setup; root validation builds the workspace graph and passed.
- `TURBO_FORCE=true pnpm typecheck` — PASS (42 tasks, 0 cached).
- `TURBO_FORCE=true pnpm lint` — PASS (23 tasks, 0 cached after one import-type remediation).
- `TURBO_FORCE=true pnpm format:check` — PASS.
- `TURBO_FORCE=true pnpm test` — PASS (42 tasks, 0 cached; expected existing integration skips only).
## Review checkpoint
- Codex code review found idempotency keys needed actor scope and concurrent retries needed an in-flight reservation; both were remediated with regression coverage.
- Codex security review found whitespace-equivalent self-delegation was accepted by the exported client; identities are now normalized before invariant checks, with regression coverage.
- Re-review added immutable payload comparison for idempotency reuse, runtime string/size validation, bounded TTL/capacity tracking for gateway and native adapter state, and fresh-correlation follow-up reads; targeted tests pass (16 coord / 7 gateway).
- Final Codex security review found no issues. PR #735 was opened from commit `7936e15d`; Woodpecker pipeline #1752 is green.

View File

@@ -0,0 +1,27 @@
# TESS-M4-003 — Operator Plugin Foundations
- **Task:** TESS-M4-003 / TESS-MEM-001
- **Branch/base:** `feat/tess-operator-plugins` rebased on `origin/main` `76325ca3`
- **Scope:** first leaf-package memory/retrieval slice only; no durable inbox ownership, gateway integration, or Mosaic catalog implementation.
## Handoff
Coder4's uncommitted implementation was preserved first in commit `5b99c821` before review. The completion pass corrected the contract so namespace is injected configuration rather than caller-selected scope data, storage keys include tenant/owner/session via collision-safe tuple encoding, and malformed runtime scope values fail closed.
## Delivered boundary
- `OperatorMemoryPlugin` exposes `capture`, `search`, `recent`, `stats`, and `startupContext` through `MemoryAdapter` only.
- Scope is server-derived `{tenantId, ownerId, sessionId}`; adapter and namespace are configuration, not operation input.
- Capture redacts before persistence and records configured instance/namespace/source provenance.
- Wildcard retrieval is documented at the `MemoryAdapter` boundary and implemented by the keyword adapter.
- Startup context uses a bounded 64-result candidate window, then prioritizes project and flat-file provenance before slicing the configured output limit.
- No `Tess` identity is hardcoded in storage keys or defaults; tests use configured `Nova`.
## Verification
- `pnpm --filter @mosaicstack/memory test` — PASS (32 tests)
- `pnpm --filter @mosaicstack/memory typecheck` — PASS
- `pnpm --filter @mosaicstack/memory lint` — PASS
- `pnpm --filter @mosaicstack/memory build` — PASS
- Codex code review — APPROVE after remediation
- Codex security review — no findings after runtime scope-validation remediation

5
docs/tess/ADMIN-GUIDE.md Normal file
View File

@@ -0,0 +1,5 @@
# Tess Administration
Configure agent/provider identities outside client input. Verify `/health/ready` and provider health before enabling interaction clients. Every interaction request requires an authenticated actor and correlation header; tenant and owner scope are server-derived. Do not log or return service credentials.
For an incident, preserve correlation IDs, inspect provider status and durable checkpoint/inbox/outbox state, then use the recovery endpoint. Do not retry an ambiguous external effect automatically. Stop operations require an exact one-time approval reference; provisioning or granting a broad admin capability does not replace that check.

View File

@@ -37,6 +37,12 @@ Required operations:
Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors.
### M1 Registry Boundary
`@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references.
Termination is fail-closed: a runtime approval verifier consumes a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. The verifier reuses the Redis-backed `interaction:command-approval:*` store and its expiry/delete-on-consume semantics; it has no parallel approval store. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary.
## Authority Model
| Intent | Owner | Tess behavior |
@@ -46,11 +52,49 @@ Every call receives an immutable, server-derived actor/tenant/channel scope and
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
### Mos Coordination Boundary
`@mosaicstack/coord` exposes only the transport-neutral `InteractionCoordinationPort`
verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant,
correlation, and interaction-agent identity from authenticated context plus
trusted configuration; callers never provide an orchestration target. It
rejects unconfigured identities, self-delegation, target/correlation drift, and
cross-tenant handoff reads before an adapter call. No dispatch, assignment,
review, merge, or cancellation API exists at this boundary.
M4 uses a deterministic native in-process queue adapter to prove the handoff →
observe → result flow without coupling the contract to tmux. A fleet/tmux
adapter is deferred to the M5 live-deployment seam and must implement the same
port.
## Session and State Model
A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation.
Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durable session bindings, approvals, audit, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth.
Valkey holds the existing short-lived, one-time command-approval records; PostgreSQL is canonical for durable session bindings, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth.
### M2 Durable Recovery
`@mosaicstack/agent` owns a transport-neutral state machine and `apps/gateway` provides its
PostgreSQL adapter. `interaction_sessions` holds immutable identity; inbox/outbox records use a
per-session unique idempotency key and transition `pending → processing → processed|delivered`.
Checkpoints are immutable history scoped by session and checkpoint ID: the latest checkpoint
supports compaction recovery, while a handoff always resolves the exact checkpoint it references.
Recovery requeues only interrupted inbox work; an ambiguous `processing` outbox record is preserved
until separately authorized reconciliation can establish its external delivery state.
Provider sends travel through the existing `RuntimeProviderService` with the persisted outbox
idempotency key. A normal dispatch claims exactly one outbox record and verifies its stored
correlation and channel against the server-derived request scope; it never requeues or drains
another live record. Inbox/outbox payloads and checkpoint cursor/summary pass through the existing
secret/PII redactor and AES-256-GCM sealing before persistence; decryption occurs only in the
scoped gateway repository path, and runtime audit remains metadata-only.
An external effect cannot share a database transaction. If a process dies after an effect begins
but before its terminal outbox transition, automatic recovery does not replay that ambiguous claim.
It remains `processing` until separately authorized reconciliation can establish delivery state;
completed effects are never redispatched. Operators can therefore restart the gateway/Pi service,
reconstruct the session, and resume pending inbox work without relying on process-local state.
## Transport Strategy
@@ -58,6 +102,12 @@ Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durabl
- **Forward:** Matrix/native Mosaic provider using authenticated identity, idempotent transaction IDs, replay cursors, and the same contract suite.
- Discord/CLI never call tmux or Matrix directly.
### Fleet/tmux Provider Boundary
`TmuxFleetRuntimeProvider` supports only rostered fleet peers. Its transport resolves the configured roster socket itself and verifies the exact `=<agent>:0.0` pane and declared runtime command before every attach, message, or termination operation. Prefixes, unrostered session IDs, unavailable sockets, dead panes, and runtime identity mismatches fail closed; callers cannot supply a socket or raw tmux target.
The provider advertises list, tree, read-only attach, send, and terminate. List/tree/health and read attach all default-deny until a scope-aware read authority permits the operation and exact peer. Attach produces a short-lived handle bound to the immutable actor, tenant, channel, and correlation scope; it never opens a server-side terminal and rejects `control` mode. Fleet stream support is intentionally absent. Tess has no direct write/control authority: send and terminate default-deny until a Mos authority adapter explicitly allows the exact session and immutable scope. The gateway registry remains the audit boundary for every requested, denied, and successful provider operation, and still consumes the exact-action termination approval before the provider is invoked.
## Plugin Families
1. Channel: Discord now; other channels later.
@@ -69,3 +119,5 @@ Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durabl
## Deployment
Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material.
The interaction-service identity is provisioning data, not a source identifier: the roster and per-agent environment carry the chosen display/roster name into a generic systemd instance. The service rejects a name mismatch or any drift from its pinned Pi/GPT-5.6 Sol/high/operator-interaction effective policy before launch. Its policy printer exposes only those resolved safe fields.

Some files were not shown because too many files have changed in this diff Show More