Compare commits
3 Commits
test/758-e
...
draft/mosa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2708de55b9 | ||
|
|
6b8c3c5d3a | ||
|
|
d1f69bfd37 |
@@ -5,5 +5,3 @@ pnpm-lock.yaml
|
|||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
.claude/
|
.claude/
|
||||||
docs/tess/TASKS.md
|
|
||||||
docs/scratchpads/
|
|
||||||
|
|||||||
@@ -7,14 +7,7 @@ variables:
|
|||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
- event: [push, pull_request, manual]
|
||||||
# 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
|
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
"@mariozechner/pi-ai": "^0.65.0",
|
"@mariozechner/pi-ai": "^0.65.0",
|
||||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@mosaicstack/agent": "workspace:^",
|
|
||||||
"@mosaicstack/auth": "workspace:^",
|
"@mosaicstack/auth": "workspace:^",
|
||||||
"@mosaicstack/brain": "workspace:^",
|
"@mosaicstack/brain": "workspace:^",
|
||||||
"@mosaicstack/config": "workspace:^",
|
"@mosaicstack/config": "workspace:^",
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -20,7 +20,7 @@ export class AdminHealthController {
|
|||||||
async check(): Promise<HealthStatusDto> {
|
async check(): Promise<HealthStatusDto> {
|
||||||
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
||||||
|
|
||||||
const sessions = this.agentService.listAllSessionsForSystem();
|
const sessions = this.agentService.listSessions();
|
||||||
const providers = this.providerService.listProviders();
|
const providers = this.providerService.listProviders();
|
||||||
|
|
||||||
const allOk = database.status === 'ok' && cache.status === 'ok';
|
const allOk = database.status === 'ok' && cache.status === 'ok';
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
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 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
|
|
||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||||
@@ -9,79 +8,24 @@ import { SkillLoaderService } from './skill-loader.service.js';
|
|||||||
import { ProvidersController } from './providers.controller.js';
|
import { ProvidersController } from './providers.controller.js';
|
||||||
import { SessionsController } from './sessions.controller.js';
|
import { SessionsController } from './sessions.controller.js';
|
||||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||||
import { InteractionController } from './interaction.controller.js';
|
|
||||||
import { RoutingController } from './routing/routing.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 { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
import { GCModule } from '../gc/gc.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 { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
|
||||||
import {
|
|
||||||
CONNECTOR_LEASE_POLICY,
|
|
||||||
ConnectorLeaseService,
|
|
||||||
DenyConnectorLeasePolicy,
|
|
||||||
} from './connector-lease.service.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()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
|
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
|
||||||
providers: [
|
providers: [
|
||||||
ProviderService,
|
ProviderService,
|
||||||
ProviderCredentialsService,
|
ProviderCredentialsService,
|
||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
DurableSessionRepository,
|
|
||||||
DurableSessionService,
|
|
||||||
ConnectorLeaseRepository,
|
|
||||||
DenyConnectorLeasePolicy,
|
|
||||||
{
|
|
||||||
provide: CONNECTOR_LEASE_POLICY,
|
|
||||||
useExisting: DenyConnectorLeasePolicy,
|
|
||||||
},
|
|
||||||
ConnectorLeaseService,
|
|
||||||
{
|
|
||||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
|
||||||
useFactory: createGatewayRuntimeProviderRegistry,
|
|
||||||
},
|
|
||||||
RuntimeProviderAuditService,
|
|
||||||
{
|
|
||||||
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
|
||||||
useExisting: RuntimeProviderAuditService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: RUNTIME_APPROVAL_VERIFIER,
|
|
||||||
useExisting: CommandRuntimeApprovalVerifier,
|
|
||||||
},
|
|
||||||
RuntimeProviderService,
|
|
||||||
AgentService,
|
AgentService,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
||||||
ProvidersController,
|
|
||||||
SessionsController,
|
|
||||||
AgentConfigsController,
|
|
||||||
InteractionController,
|
|
||||||
RoutingController,
|
|
||||||
],
|
|
||||||
exports: [
|
exports: [
|
||||||
AgentService,
|
AgentService,
|
||||||
ProviderService,
|
ProviderService,
|
||||||
@@ -89,10 +33,6 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
DurableSessionService,
|
|
||||||
RuntimeProviderService,
|
|
||||||
ConnectorLeaseService,
|
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
||||||
ForbiddenException,
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
Optional,
|
|
||||||
type OnModuleDestroy,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
@@ -15,11 +8,9 @@ import {
|
|||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
} from '@mariozechner/pi-coding-agent';
|
} from '@mariozechner/pi-coding-agent';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
import type { Memory } from '@mosaicstack/memory';
|
||||||
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
|
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { MEMORY } from '../memory/memory.tokens.js';
|
import { MEMORY } from '../memory/memory.tokens.js';
|
||||||
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
|
|
||||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||||
import { CoordService } from '../coord/coord.service.js';
|
import { CoordService } from '../coord/coord.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
@@ -37,15 +28,12 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
|
|||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.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. */
|
/** A single message from DB conversation history, used for context injection. */
|
||||||
export interface ConversationHistoryMessage {
|
export interface ConversationHistoryMessage {
|
||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
/** Validated, URI-referenced channel attachments preserved on session resume. */
|
|
||||||
attachments?: readonly ChannelAttachmentDto[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSessionOptions {
|
export interface AgentSessionOptions {
|
||||||
@@ -80,8 +68,6 @@ export interface AgentSessionOptions {
|
|||||||
agentConfigId?: string;
|
agentConfigId?: string;
|
||||||
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
||||||
userId?: string;
|
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.
|
* Prior conversation messages to inject as context when resuming a session.
|
||||||
* These messages are formatted and prepended to the system prompt so the
|
* These messages are formatted and prepended to the system prompt so the
|
||||||
@@ -108,8 +94,6 @@ export interface AgentSession {
|
|||||||
allowedTools: string[] | null;
|
allowedTools: string[] | null;
|
||||||
/** User ID that owns this session, used for preference lookups. */
|
/** User ID that owns this session, used for preference lookups. */
|
||||||
userId?: string;
|
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). */
|
/** Agent config ID applied to this session, if any (M5-001). */
|
||||||
agentConfigId?: string;
|
agentConfigId?: string;
|
||||||
/** Human-readable agent name applied to this session, if any (M5-001). */
|
/** Human-readable agent name applied to this session, if any (M5-001). */
|
||||||
@@ -139,9 +123,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||||
@Optional()
|
|
||||||
@Inject(OPERATOR_MEMORY_PLUGIN)
|
|
||||||
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,7 +134,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
private buildToolsForSandbox(
|
private buildToolsForSandbox(
|
||||||
sandboxDir: string,
|
sandboxDir: string,
|
||||||
sessionUserId: string | undefined,
|
sessionUserId: string | undefined,
|
||||||
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
|
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
...createBrainTools(this.brain),
|
...createBrainTools(this.brain),
|
||||||
@@ -162,9 +142,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
this.memory,
|
this.memory,
|
||||||
this.embeddingService.available ? this.embeddingService : null,
|
this.embeddingService.available ? this.embeddingService : null,
|
||||||
sessionUserId,
|
sessionUserId,
|
||||||
this.operatorMemory && sessionScope
|
|
||||||
? { plugin: this.operatorMemory, scope: sessionScope }
|
|
||||||
: undefined,
|
|
||||||
),
|
),
|
||||||
...createFileTools(sandboxDir),
|
...createFileTools(sandboxDir),
|
||||||
...createGitTools(sandboxDir),
|
...createGitTools(sandboxDir),
|
||||||
@@ -197,20 +174,12 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
.filter((t) => t.length > 0);
|
.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);
|
const existing = this.sessions.get(sessionId);
|
||||||
if (existing) {
|
if (existing) return existing;
|
||||||
this.assertSessionScope(existing, scope);
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflight = this.creating.get(sessionId);
|
const inflight = this.creating.get(sessionId);
|
||||||
if (inflight) {
|
if (inflight) return inflight;
|
||||||
const session = await inflight;
|
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
||||||
this.creating.delete(sessionId);
|
this.creating.delete(sessionId);
|
||||||
@@ -239,7 +208,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
isAdmin: options.isAdmin,
|
isAdmin: options.isAdmin,
|
||||||
agentConfigId: options.agentConfigId,
|
agentConfigId: options.agentConfigId,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
tenantId: options.tenantId,
|
|
||||||
conversationHistory: options.conversationHistory,
|
conversationHistory: options.conversationHistory,
|
||||||
};
|
};
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -279,15 +247,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build per-session tools scoped to the sandbox directory and authenticated user
|
// Build per-session tools scoped to the sandbox directory and authenticated user
|
||||||
const sessionUserId = mergedOptions?.userId;
|
const sandboxTools = this.buildToolsForSandbox(sandboxDir, 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
|
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
||||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||||
@@ -382,7 +342,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
userId: mergedOptions?.userId,
|
userId: mergedOptions?.userId,
|
||||||
tenantId: sessionTenantId,
|
|
||||||
agentConfigId: mergedOptions?.agentConfigId,
|
agentConfigId: mergedOptions?.agentConfigId,
|
||||||
agentName: resolvedAgentName,
|
agentName: resolvedAgentName,
|
||||||
metrics: {
|
metrics: {
|
||||||
@@ -431,7 +390,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
const formatMessage = (msg: ConversationHistoryMessage): string => {
|
const formatMessage = (msg: ConversationHistoryMessage): string => {
|
||||||
const roleLabel =
|
const roleLabel =
|
||||||
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
|
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
|
||||||
return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`;
|
return `**${roleLabel}:** ${msg.content}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatted = history.map((msg) => formatMessage(msg));
|
const formatted = history.map((msg) => formatMessage(msg));
|
||||||
@@ -490,21 +449,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachmentContext(attachments: readonly ChannelAttachmentDto[]): string {
|
|
||||||
if (attachments.length === 0) return '';
|
|
||||||
return `\n\n[Untrusted channel attachments]\n${attachments
|
|
||||||
.map((attachment: ChannelAttachmentDto): string =>
|
|
||||||
JSON.stringify({
|
|
||||||
id: attachment.id,
|
|
||||||
name: attachment.name,
|
|
||||||
mimeType: attachment.mimeType,
|
|
||||||
url: attachment.url,
|
|
||||||
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.join('\n')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveModel(options?: AgentSessionOptions) {
|
private resolveModel(options?: AgentSessionOptions) {
|
||||||
if (!options?.provider && !options?.modelId) {
|
if (!options?.provider && !options?.modelId) {
|
||||||
return this.providerService.getDefaultModel() ?? null;
|
return this.providerService.getDefaultModel() ?? null;
|
||||||
@@ -529,70 +473,38 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
return this.providerService.getDefaultModel() ?? null;
|
return this.providerService.getDefaultModel() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
|
getSession(sessionId: string): AgentSession | undefined {
|
||||||
const session = this.sessions.get(sessionId);
|
return this.sessions.get(sessionId);
|
||||||
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
|
listSessions(): SessionInfoDto[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return Array.from(this.sessions.values())
|
return Array.from(this.sessions.values()).map((s) => ({
|
||||||
.filter((s) => this.sessionMatchesScope(s, scope))
|
id: s.id,
|
||||||
.map((s) => this.toSessionInfo(s, now));
|
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 },
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
listAllSessionsForSystem(): SessionInfoDto[] {
|
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
|
||||||
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);
|
const s = this.sessions.get(sessionId);
|
||||||
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
|
if (!s) return undefined;
|
||||||
return this.toSessionInfo(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
|
|
||||||
if (!options.userId) {
|
|
||||||
throw new ForbiddenException('Session owner scope is required');
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
userId: options.userId,
|
id: s.id,
|
||||||
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
|
provider: s.provider,
|
||||||
};
|
modelId: s.modelId,
|
||||||
}
|
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||||
|
createdAt: new Date(s.createdAt).toISOString(),
|
||||||
private tenantIdFor(
|
promptCount: s.promptCount,
|
||||||
userId: string | undefined,
|
channels: Array.from(s.channels),
|
||||||
tenantId: string | undefined,
|
durationMs: Date.now() - s.createdAt,
|
||||||
): string | undefined {
|
metrics: { ...s.metrics },
|
||||||
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 },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,10 +553,9 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
* not reconstructed — the model is used on the next createSession call for
|
* 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.
|
* the same conversationId when the session is torn down or a new one is created.
|
||||||
*/
|
*/
|
||||||
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
|
updateSessionModel(sessionId: string, modelId: string): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
const prev = session.modelId;
|
const prev = session.modelId;
|
||||||
session.modelId = modelId;
|
session.modelId = modelId;
|
||||||
this.recordModelSwitch(sessionId);
|
this.recordModelSwitch(sessionId);
|
||||||
@@ -661,67 +572,48 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
agentConfigId: string,
|
agentConfigId: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
scope: ActorTenantScope,
|
|
||||||
modelId?: string,
|
modelId?: string,
|
||||||
): void {
|
): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
session.agentConfigId = agentConfigId;
|
session.agentConfigId = agentConfigId;
|
||||||
session.agentName = agentName;
|
session.agentName = agentName;
|
||||||
if (modelId) {
|
if (modelId) {
|
||||||
this.updateSessionModel(sessionId, modelId, scope);
|
this.updateSessionModel(sessionId, modelId);
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
addChannel(sessionId: string, channel: string): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (session) {
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
session.channels.add(channel);
|
session.channels.add(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
session.channels.delete(channel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void>;
|
removeChannel(sessionId: string, channel: string): void {
|
||||||
async prompt(
|
const session = this.sessions.get(sessionId);
|
||||||
sessionId: string,
|
if (session) {
|
||||||
message: string,
|
session.channels.delete(channel);
|
||||||
scope: ActorTenantScope,
|
}
|
||||||
attachments: readonly ChannelAttachmentDto[] | undefined,
|
}
|
||||||
): Promise<void>;
|
|
||||||
async prompt(
|
async prompt(sessionId: string, message: string): Promise<void> {
|
||||||
sessionId: string,
|
|
||||||
message: string,
|
|
||||||
scope: ActorTenantScope,
|
|
||||||
attachments: readonly ChannelAttachmentDto[] = [],
|
|
||||||
): Promise<void> {
|
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
session.promptCount += 1;
|
session.promptCount += 1;
|
||||||
|
|
||||||
// Channel attachments are untrusted URI references. Preserve exact,
|
|
||||||
// authenticated metadata for the agent without treating it as authority.
|
|
||||||
const attachmentContext = this.attachmentContext(attachments);
|
|
||||||
|
|
||||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||||
let effectiveMessage = `${message}${attachmentContext}`;
|
let effectiveMessage = message;
|
||||||
if (this.systemOverride) {
|
if (this.systemOverride) {
|
||||||
const override = await this.systemOverride.get(sessionId, scope);
|
const override = await this.systemOverride.get(sessionId);
|
||||||
if (override) {
|
if (override) {
|
||||||
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||||
await this.systemOverride.renew(sessionId, scope);
|
await this.systemOverride.renew(sessionId);
|
||||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -737,28 +629,16 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onEvent(
|
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
|
||||||
sessionId: string,
|
|
||||||
listener: (event: AgentSessionEvent) => void,
|
|
||||||
scope: ActorTenantScope,
|
|
||||||
): () => void {
|
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
this.assertSessionScope(session, scope);
|
|
||||||
session.listeners.add(listener);
|
session.listeners.add(listener);
|
||||||
return () => session.listeners.delete(listener);
|
return () => session.listeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async destroySession(sessionId: string): 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);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.logger.log(`Destroying agent session ${sessionId}`);
|
this.logger.log(`Destroying agent session ${sessionId}`);
|
||||||
@@ -787,7 +667,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
this.logger.log('Shutting down all agent sessions');
|
this.logger.log('Shutting down all agent sessions');
|
||||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
|
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
|
||||||
const results = await Promise.allSettled(stops);
|
const results = await Promise.allSettled(stops);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { Test, type TestingModule } from '@nestjs/testing';
|
|
||||||
import {
|
|
||||||
connectorLeaseAuditLog,
|
|
||||||
createPgliteDb,
|
|
||||||
eq,
|
|
||||||
runPgliteMigrations,
|
|
||||||
type DbHandle,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import type { ConnectorExecutionContext, FencedConnectorAdapter } from '@mosaicstack/types';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
|
||||||
import {
|
|
||||||
CONNECTOR_LEASE_POLICY,
|
|
||||||
ConnectorLeaseService,
|
|
||||||
type ConnectorLeasePolicy,
|
|
||||||
type ConnectorLeasePolicySubject,
|
|
||||||
} from './connector-lease.service.js';
|
|
||||||
|
|
||||||
const authorize = vi.fn().mockResolvedValue(true);
|
|
||||||
const policy: ConnectorLeasePolicy = { authorize };
|
|
||||||
const context = {
|
|
||||||
actorScope: { userId: 'operator-a', tenantId: 'tenant-a' },
|
|
||||||
correlationId: 'correlation-acquire',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('gateway connector lease fencing integration', (): void => {
|
|
||||||
let dataDir: string;
|
|
||||||
let handle: DbHandle;
|
|
||||||
let moduleRef: TestingModule;
|
|
||||||
let service: ConnectorLeaseService;
|
|
||||||
let repository: ConnectorLeaseRepository;
|
|
||||||
|
|
||||||
beforeAll(async (): Promise<void> => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date('2026-07-14T17:00:00.000Z'));
|
|
||||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-connector-lease-'));
|
|
||||||
handle = createPgliteDb(dataDir);
|
|
||||||
await runPgliteMigrations(handle);
|
|
||||||
moduleRef = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
ConnectorLeaseRepository,
|
|
||||||
ConnectorLeaseService,
|
|
||||||
{ provide: DB, useValue: handle.db },
|
|
||||||
{ provide: CONNECTOR_LEASE_POLICY, useValue: policy },
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
service = moduleRef.get(ConnectorLeaseService);
|
|
||||||
repository = moduleRef.get(ConnectorLeaseRepository);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async (): Promise<void> => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
await moduleRef.close();
|
|
||||||
await handle.close();
|
|
||||||
await rm(dataDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives tenant authority at the gateway and validates a grant before side effects', async (): Promise<void> => {
|
|
||||||
const lease = await service.acquire(
|
|
||||||
{
|
|
||||||
logicalAgentId: 'Mos',
|
|
||||||
bindingId: 'operator-chat',
|
|
||||||
connectorId: 'pi-worker-a',
|
|
||||||
scopes: ['runtime.send'],
|
|
||||||
ttlMs: 60_000,
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
const grant = await service.issueGrant(
|
|
||||||
{ lease, scopes: ['runtime.send'], ttlMs: 30_000 },
|
|
||||||
{ ...context, correlationId: 'correlation-grant' },
|
|
||||||
);
|
|
||||||
const execute = vi.fn(async (_message: string, leaseContext: ConnectorExecutionContext) => {
|
|
||||||
return leaseContext.leaseEpoch;
|
|
||||||
});
|
|
||||||
const adapter: FencedConnectorAdapter<string, string> = { execute };
|
|
||||||
|
|
||||||
await expect(service.executeGrant(grant, 'runtime.send', 'hello', adapter)).resolves.toBe('1');
|
|
||||||
expect(execute).toHaveBeenCalledOnce();
|
|
||||||
expect(authorize).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
action: 'grant.issue',
|
|
||||||
requestedScopes: ['runtime.send'],
|
|
||||||
requestedTtlMs: 30_000,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(execute.mock.calls[0]?.[1]).toMatchObject({
|
|
||||||
identity: { tenantId: 'tenant-a', logicalAgentId: 'mos' },
|
|
||||||
bindingId: 'operator-chat',
|
|
||||||
connectorId: 'pi-worker-a',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes lease-derived policy subjects before authorization', async (): Promise<void> => {
|
|
||||||
const lease = await service.acquire(
|
|
||||||
{
|
|
||||||
logicalAgentId: 'mos',
|
|
||||||
bindingId: 'operator-chat-policy',
|
|
||||||
connectorId: 'pi-worker-a',
|
|
||||||
scopes: ['runtime.send'],
|
|
||||||
ttlMs: 60_000,
|
|
||||||
},
|
|
||||||
{ ...context, correlationId: 'correlation-policy-setup' },
|
|
||||||
);
|
|
||||||
const aliasedLease = {
|
|
||||||
...lease,
|
|
||||||
identity: { ...lease.identity, logicalAgentId: ' MOS ' },
|
|
||||||
bindingId: ' Operator-Chat-Policy ',
|
|
||||||
connectorId: ' PI-Worker-A ',
|
|
||||||
scopes: [' Runtime.Send '],
|
|
||||||
leaseEpoch: `00${lease.leaseEpoch}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
await service.heartbeat(aliasedLease, 30_000, {
|
|
||||||
...context,
|
|
||||||
correlationId: 'correlation-policy-heartbeat',
|
|
||||||
});
|
|
||||||
expect(authorize).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
action: 'lease.heartbeat',
|
|
||||||
logicalAgentId: 'mos',
|
|
||||||
bindingId: 'operator-chat-policy',
|
|
||||||
connectorId: 'pi-worker-a',
|
|
||||||
requestedScopes: ['runtime.send'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.issueGrant(
|
|
||||||
{ lease: aliasedLease, scopes: [' Runtime.Send '], ttlMs: 1_000 },
|
|
||||||
{ ...context, correlationId: 'correlation-policy-grant' },
|
|
||||||
);
|
|
||||||
expect(authorize).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
action: 'grant.issue',
|
|
||||||
logicalAgentId: 'mos',
|
|
||||||
bindingId: 'operator-chat-policy',
|
|
||||||
connectorId: 'pi-worker-a',
|
|
||||||
requestedScopes: ['runtime.send'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.release(aliasedLease, {
|
|
||||||
...context,
|
|
||||||
correlationId: 'correlation-policy-release',
|
|
||||||
});
|
|
||||||
expect(authorize).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
action: 'lease.release',
|
|
||||||
logicalAgentId: 'mos',
|
|
||||||
bindingId: 'operator-chat-policy',
|
|
||||||
connectorId: 'pi-worker-a',
|
|
||||||
requestedScopes: ['runtime.send'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies stale, forged, expired, cross-tenant, and cross-binding grants before effects', async (): Promise<void> => {
|
|
||||||
const bindingId = 'operator-chat-denials';
|
|
||||||
const current = await service.acquire(
|
|
||||||
{
|
|
||||||
logicalAgentId: 'mos',
|
|
||||||
bindingId,
|
|
||||||
connectorId: 'pi-worker-a',
|
|
||||||
scopes: ['runtime.send'],
|
|
||||||
ttlMs: 60_000,
|
|
||||||
},
|
|
||||||
{ ...context, correlationId: 'correlation-denial-setup' },
|
|
||||||
);
|
|
||||||
const stale = await service.issueGrant(
|
|
||||||
{ lease: current, scopes: ['runtime.send'], ttlMs: 30_000 },
|
|
||||||
{ ...context, correlationId: 'correlation-stale' },
|
|
||||||
);
|
|
||||||
await service.takeover(
|
|
||||||
{
|
|
||||||
logicalAgentId: 'mos',
|
|
||||||
bindingId,
|
|
||||||
connectorId: 'pi-worker-b',
|
|
||||||
scopes: ['runtime.send'],
|
|
||||||
ttlMs: 60_000,
|
|
||||||
expectedEpoch: current.leaseEpoch,
|
|
||||||
},
|
|
||||||
{ ...context, correlationId: 'correlation-takeover' },
|
|
||||||
);
|
|
||||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
|
|
||||||
await expect(service.executeGrant(stale, 'runtime.send', undefined, adapter)).rejects.toThrow();
|
|
||||||
|
|
||||||
const active = await service.current('mos', bindingId, context);
|
|
||||||
if (!active) throw new Error('active lease fixture is unavailable');
|
|
||||||
const grant = await service.issueGrant(
|
|
||||||
{ lease: active, scopes: ['runtime.send'], ttlMs: 1_000 },
|
|
||||||
{ ...context, correlationId: 'correlation-active' },
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
service.executeGrant({ ...grant }, 'runtime.send', undefined, adapter),
|
|
||||||
).rejects.toThrow();
|
|
||||||
await expect(
|
|
||||||
service.executeGrant(
|
|
||||||
{ ...grant, bindingId: 'other-binding' },
|
|
||||||
'runtime.send',
|
|
||||||
undefined,
|
|
||||||
adapter,
|
|
||||||
),
|
|
||||||
).rejects.toThrow();
|
|
||||||
await expect(
|
|
||||||
service.issueGrant(
|
|
||||||
{ lease: active, scopes: ['runtime.send'], ttlMs: 30_000 },
|
|
||||||
{
|
|
||||||
actorScope: { userId: 'operator-b', tenantId: 'tenant-b' },
|
|
||||||
correlationId: 'correlation-cross-tenant',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).rejects.toThrow();
|
|
||||||
const crossTenantAudit = await handle.db
|
|
||||||
.select()
|
|
||||||
.from(connectorLeaseAuditLog)
|
|
||||||
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-cross-tenant'));
|
|
||||||
expect(crossTenantAudit).toHaveLength(1);
|
|
||||||
expect(crossTenantAudit[0]).toMatchObject({
|
|
||||||
tenantId: 'tenant-b',
|
|
||||||
logicalAgentId: 'untrusted',
|
|
||||||
bindingId: 'untrusted',
|
|
||||||
connectorId: 'untrusted',
|
|
||||||
reason: 'policy_denied',
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.setSystemTime(new Date('2026-07-14T17:00:02.000Z'));
|
|
||||||
await expect(service.executeGrant(grant, 'runtime.send', undefined, adapter)).rejects.toThrow();
|
|
||||||
expect(adapter.execute).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects submitted lifecycle scopes that differ from durable authority before policy or mutation', async (): Promise<void> => {
|
|
||||||
authorize.mockResolvedValue(true);
|
|
||||||
const heartbeatLease = await service.acquire(
|
|
||||||
{
|
|
||||||
logicalAgentId: 'mos',
|
|
||||||
bindingId: 'operator-chat-heartbeat-scope',
|
|
||||||
connectorId: 'pi-worker-a',
|
|
||||||
scopes: ['runtime.send'],
|
|
||||||
ttlMs: 60_000,
|
|
||||||
},
|
|
||||||
{ ...context, correlationId: 'correlation-heartbeat-scope-setup' },
|
|
||||||
);
|
|
||||||
const releaseLease = await service.acquire(
|
|
||||||
{
|
|
||||||
logicalAgentId: 'mos',
|
|
||||||
bindingId: 'operator-chat-release-scope',
|
|
||||||
connectorId: 'pi-worker-a',
|
|
||||||
scopes: ['runtime.send'],
|
|
||||||
ttlMs: 60_000,
|
|
||||||
},
|
|
||||||
{ ...context, correlationId: 'correlation-release-scope-setup' },
|
|
||||||
);
|
|
||||||
const forgedHeartbeat = { ...heartbeatLease, scopes: ['tool.execute'] };
|
|
||||||
const forgedRelease = { ...releaseLease, scopes: ['tool.execute'] };
|
|
||||||
|
|
||||||
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
|
|
||||||
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'tool.execute';
|
|
||||||
});
|
|
||||||
authorize.mockClear();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.heartbeat(forgedHeartbeat, 30_000, {
|
|
||||||
...context,
|
|
||||||
correlationId: 'correlation-heartbeat-scope-forgery',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('Connector authority policy denied');
|
|
||||||
await expect(
|
|
||||||
service.release(forgedRelease, {
|
|
||||||
...context,
|
|
||||||
correlationId: 'correlation-release-scope-forgery',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('Connector authority policy denied');
|
|
||||||
expect(authorize).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const currentHeartbeat = await repository.findCurrent({
|
|
||||||
identity: heartbeatLease.identity,
|
|
||||||
bindingId: heartbeatLease.bindingId,
|
|
||||||
});
|
|
||||||
const currentRelease = await repository.findCurrent({
|
|
||||||
identity: releaseLease.identity,
|
|
||||||
bindingId: releaseLease.bindingId,
|
|
||||||
});
|
|
||||||
expect(currentHeartbeat).toMatchObject({
|
|
||||||
leaseId: heartbeatLease.leaseId,
|
|
||||||
scopes: ['runtime.send'],
|
|
||||||
heartbeatAt: heartbeatLease.heartbeatAt,
|
|
||||||
expiresAt: heartbeatLease.expiresAt,
|
|
||||||
});
|
|
||||||
expect(currentRelease).toMatchObject({
|
|
||||||
leaseId: releaseLease.leaseId,
|
|
||||||
scopes: ['runtime.send'],
|
|
||||||
});
|
|
||||||
expect(currentRelease?.releasedAt).toBeUndefined();
|
|
||||||
|
|
||||||
const forgedAudits = await handle.db
|
|
||||||
.select()
|
|
||||||
.from(connectorLeaseAuditLog)
|
|
||||||
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-heartbeat-scope-forgery'));
|
|
||||||
expect(forgedAudits).toHaveLength(1);
|
|
||||||
expect(forgedAudits[0]).toMatchObject({
|
|
||||||
bindingId: heartbeatLease.bindingId,
|
|
||||||
connectorId: heartbeatLease.connectorId,
|
|
||||||
event: 'reject',
|
|
||||||
outcome: 'denied',
|
|
||||||
reason: 'policy_denied',
|
|
||||||
});
|
|
||||||
const forgedReleaseAudits = await handle.db
|
|
||||||
.select()
|
|
||||||
.from(connectorLeaseAuditLog)
|
|
||||||
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-release-scope-forgery'));
|
|
||||||
expect(forgedReleaseAudits).toHaveLength(1);
|
|
||||||
expect(forgedReleaseAudits[0]).toMatchObject({
|
|
||||||
bindingId: releaseLease.bindingId,
|
|
||||||
connectorId: releaseLease.connectorId,
|
|
||||||
event: 'reject',
|
|
||||||
outcome: 'denied',
|
|
||||||
reason: 'policy_denied',
|
|
||||||
});
|
|
||||||
|
|
||||||
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
|
|
||||||
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'runtime.send';
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
service.heartbeat(heartbeatLease, 30_000, {
|
|
||||||
...context,
|
|
||||||
correlationId: 'correlation-heartbeat-scope-canonical',
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({ scopes: ['runtime.send'] });
|
|
||||||
await expect(
|
|
||||||
service.release(releaseLease, {
|
|
||||||
...context,
|
|
||||||
correlationId: 'correlation-release-scope-canonical',
|
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
connectorLeaseAuditLog,
|
|
||||||
createDb,
|
|
||||||
eq,
|
|
||||||
logicalAgentConnectorLeases,
|
|
||||||
type DbHandle,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import { ConnectorLeaseCoordinator } from '@mosaicstack/agent';
|
|
||||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
|
||||||
|
|
||||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
|
||||||
const tenantId = `lease-test-${randomUUID()}`;
|
|
||||||
const identity = { tenantId, logicalAgentId: 'mos' } as const;
|
|
||||||
|
|
||||||
describe.skipIf(!hasPostgres)('ConnectorLeaseRepository real PostgreSQL integration', (): void => {
|
|
||||||
let handle: DbHandle;
|
|
||||||
|
|
||||||
beforeAll((): void => {
|
|
||||||
handle = createDb(process.env['DATABASE_URL']);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async (): Promise<void> => {
|
|
||||||
if (!handle) return;
|
|
||||||
await handle.db
|
|
||||||
.delete(connectorLeaseAuditLog)
|
|
||||||
.where(eq(connectorLeaseAuditLog.tenantId, tenantId));
|
|
||||||
await handle.db
|
|
||||||
.delete(logicalAgentConnectorLeases)
|
|
||||||
.where(eq(logicalAgentConnectorLeases.tenantId, tenantId));
|
|
||||||
await handle.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves the exclusive CAS fence across a real pool close/reopen', async (): Promise<void> => {
|
|
||||||
const command = {
|
|
||||||
identity,
|
|
||||||
bindingId: 'operator-chat',
|
|
||||||
scopes: ['runtime.send'],
|
|
||||||
ttlMs: 60_000,
|
|
||||||
} as const;
|
|
||||||
const firstCoordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
|
|
||||||
const contenders = await Promise.allSettled([
|
|
||||||
firstCoordinator.acquire({
|
|
||||||
...command,
|
|
||||||
connectorId: 'connector-a',
|
|
||||||
correlationId: 'postgres-acquire-a',
|
|
||||||
}),
|
|
||||||
firstCoordinator.acquire({
|
|
||||||
...command,
|
|
||||||
connectorId: 'connector-b',
|
|
||||||
correlationId: 'postgres-acquire-b',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const acquired = contenders.find((result) => result.status === 'fulfilled');
|
|
||||||
if (!acquired || acquired.status !== 'fulfilled') throw new Error('no lease contender won');
|
|
||||||
expect(contenders.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
||||||
|
|
||||||
await handle.close();
|
|
||||||
handle = createDb(process.env['DATABASE_URL']);
|
|
||||||
const reopened = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
|
|
||||||
const persisted = await reopened.current({ identity, bindingId: 'operator-chat' });
|
|
||||||
expect(persisted).toMatchObject({
|
|
||||||
leaseId: acquired.value.leaseId,
|
|
||||||
leaseEpoch: '1',
|
|
||||||
});
|
|
||||||
|
|
||||||
const takeover = await reopened.takeover({
|
|
||||||
...command,
|
|
||||||
connectorId: 'connector-c',
|
|
||||||
correlationId: 'postgres-takeover',
|
|
||||||
expectedEpoch: acquired.value.leaseEpoch,
|
|
||||||
});
|
|
||||||
expect(takeover).toMatchObject({ connectorId: 'connector-c', leaseEpoch: '2' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
connectorLeaseAuditLog,
|
|
||||||
createPgliteDb,
|
|
||||||
eq,
|
|
||||||
runPgliteMigrations,
|
|
||||||
type DbHandle,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import { ConnectorLeaseCoordinator, ConnectorLeaseError } from '@mosaicstack/agent';
|
|
||||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
|
||||||
|
|
||||||
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
|
|
||||||
|
|
||||||
function acquireCommand(connectorId: string, correlationId: string) {
|
|
||||||
return {
|
|
||||||
identity,
|
|
||||||
bindingId: 'operator-chat',
|
|
||||||
connectorId,
|
|
||||||
scopes: ['runtime.send', 'tool.execute'],
|
|
||||||
ttlMs: 60_000,
|
|
||||||
correlationId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ConnectorLeaseRepository PostgreSQL semantics', (): void => {
|
|
||||||
let dataDir: string;
|
|
||||||
let handle: DbHandle;
|
|
||||||
let now: Date;
|
|
||||||
let coordinator: ConnectorLeaseCoordinator;
|
|
||||||
|
|
||||||
beforeEach(async (): Promise<void> => {
|
|
||||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-connector-lease-'));
|
|
||||||
handle = createPgliteDb(dataDir);
|
|
||||||
await runPgliteMigrations(handle);
|
|
||||||
now = new Date('2026-07-14T17:00:00.000Z');
|
|
||||||
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
|
|
||||||
now: (): Date => now,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
await handle.close();
|
|
||||||
await rm(dataDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows only one concurrent contender to acquire a binding', async (): Promise<void> => {
|
|
||||||
const outcomes = await Promise.allSettled([
|
|
||||||
coordinator.acquire(acquireCommand('connector-a', 'correlation-a')),
|
|
||||||
coordinator.acquire(acquireCommand('connector-b', 'correlation-b')),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
||||||
const rejected = outcomes.find((result) => result.status === 'rejected');
|
|
||||||
expect(rejected).toMatchObject({
|
|
||||||
reason: { code: 'lease_held' } satisfies Partial<ConnectorLeaseError>,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise<void> => {
|
|
||||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
|
||||||
const results = await Promise.allSettled([
|
|
||||||
coordinator.takeover({
|
|
||||||
...acquireCommand('connector-b', 'correlation-b'),
|
|
||||||
expectedEpoch: acquired.leaseEpoch,
|
|
||||||
}),
|
|
||||||
coordinator.takeover({
|
|
||||||
...acquireCommand('connector-c', 'correlation-c'),
|
|
||||||
expectedEpoch: acquired.leaseEpoch,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const winner = results.find((result) => result.status === 'fulfilled');
|
|
||||||
|
|
||||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
||||||
expect(winner?.status === 'fulfilled' ? winner.value.leaseEpoch : null).toBe('2');
|
|
||||||
expect(results.find((result) => result.status === 'rejected')).toMatchObject({
|
|
||||||
reason: { code: 'cas_mismatch' } satisfies Partial<ConnectorLeaseError>,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('heartbeats and releases only the current connector epoch', async (): Promise<void> => {
|
|
||||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
|
||||||
now = new Date('2026-07-14T17:00:30.000Z');
|
|
||||||
const renewed = await coordinator.heartbeat({
|
|
||||||
lease: acquired,
|
|
||||||
ttlMs: 120_000,
|
|
||||||
correlationId: 'correlation-renew',
|
|
||||||
});
|
|
||||||
expect(renewed.expiresAt).toBe('2026-07-14T17:02:30.000Z');
|
|
||||||
|
|
||||||
await coordinator.release({ lease: renewed, correlationId: 'correlation-release' });
|
|
||||||
await expect(
|
|
||||||
coordinator.heartbeat({
|
|
||||||
lease: renewed,
|
|
||||||
ttlMs: 120_000,
|
|
||||||
correlationId: 'correlation-stale',
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({ code: 'lease_released' } satisfies Partial<ConnectorLeaseError>);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise<void> => {
|
|
||||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
|
||||||
await handle.close();
|
|
||||||
|
|
||||||
now = new Date('2026-07-14T17:02:00.000Z');
|
|
||||||
handle = createPgliteDb(dataDir);
|
|
||||||
await runPgliteMigrations(handle);
|
|
||||||
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
|
|
||||||
now: (): Date => now,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
coordinator.acquire(acquireCommand('connector-b', 'correlation-plain-acquire')),
|
|
||||||
).rejects.toMatchObject({ code: 'takeover_required' } satisfies Partial<ConnectorLeaseError>);
|
|
||||||
const recovered = await coordinator.takeover({
|
|
||||||
...acquireCommand('connector-b', 'correlation-takeover'),
|
|
||||||
expectedEpoch: acquired.leaseEpoch,
|
|
||||||
});
|
|
||||||
expect(recovered).toMatchObject({ connectorId: 'connector-b', leaseEpoch: '2' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes credential-safe lifecycle and rejection audit records', async (): Promise<void> => {
|
|
||||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
|
||||||
await coordinator.heartbeat({
|
|
||||||
lease: acquired,
|
|
||||||
ttlMs: 60_000,
|
|
||||||
correlationId: 'correlation-renew',
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
coordinator.acquire(acquireCommand('connector-b', 'correlation-reject')),
|
|
||||||
).rejects.toBeInstanceOf(ConnectorLeaseError);
|
|
||||||
|
|
||||||
const rows = await handle.db
|
|
||||||
.select()
|
|
||||||
.from(connectorLeaseAuditLog)
|
|
||||||
.where(eq(connectorLeaseAuditLog.tenantId, identity.tenantId));
|
|
||||||
expect(rows.map((row) => row.event)).toEqual(
|
|
||||||
expect.arrayContaining(['acquire', 'renew', 'reject']),
|
|
||||||
);
|
|
||||||
const serialized = JSON.stringify(rows, (_key: string, value: unknown): unknown =>
|
|
||||||
typeof value === 'bigint' ? value.toString(10) : value,
|
|
||||||
);
|
|
||||||
expect(serialized).not.toContain('tool.execute');
|
|
||||||
expect(serialized).not.toContain('runtime.send');
|
|
||||||
expect(serialized).not.toMatch(/token|secret|credential/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
and,
|
|
||||||
connectorLeaseAuditLog,
|
|
||||||
eq,
|
|
||||||
gt,
|
|
||||||
isNull,
|
|
||||||
logicalAgentConnectorLeases,
|
|
||||||
sql,
|
|
||||||
type Db,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import { ConnectorLeaseError } from '@mosaicstack/agent';
|
|
||||||
import type {
|
|
||||||
ConnectorLease,
|
|
||||||
ConnectorLeaseAcquireMutation,
|
|
||||||
ConnectorLeaseAuditEvent,
|
|
||||||
ConnectorLeaseHeartbeatMutation,
|
|
||||||
ConnectorLeaseRejectReason,
|
|
||||||
ConnectorLeaseReleaseMutation,
|
|
||||||
ConnectorLeaseStore,
|
|
||||||
ConnectorLeaseTakeoverMutation,
|
|
||||||
LogicalAgentBinding,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
|
|
||||||
interface SuccessfulMutation {
|
|
||||||
readonly ok: true;
|
|
||||||
readonly lease: ConnectorLease;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FailedMutation {
|
|
||||||
readonly ok: false;
|
|
||||||
readonly reason: ConnectorLeaseRejectReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MutationResult = SuccessfulMutation | FailedMutation;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ConnectorLeaseRepository implements ConnectorLeaseStore {
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
async acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease> {
|
|
||||||
const result: MutationResult = await this.db.transaction(
|
|
||||||
async (tx): Promise<MutationResult> => {
|
|
||||||
const inserted = await tx
|
|
||||||
.insert(logicalAgentConnectorLeases)
|
|
||||||
.values({
|
|
||||||
leaseId: input.leaseId,
|
|
||||||
tenantId: input.identity.tenantId,
|
|
||||||
logicalAgentId: input.identity.logicalAgentId,
|
|
||||||
bindingId: input.bindingId,
|
|
||||||
connectorId: input.connectorId,
|
|
||||||
scopes: [...input.scopes],
|
|
||||||
leaseEpoch: 1n,
|
|
||||||
acquiredAt: new Date(input.now),
|
|
||||||
heartbeatAt: new Date(input.now),
|
|
||||||
expiresAt: new Date(input.expiresAt),
|
|
||||||
updatedAt: new Date(input.now),
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning();
|
|
||||||
const row = inserted[0];
|
|
||||||
if (row) {
|
|
||||||
const lease = toLease(row);
|
|
||||||
await insertAudit(tx, lifecycleAudit(input, lease, 'acquire'));
|
|
||||||
return { ok: true, lease };
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = await findRow(tx, input);
|
|
||||||
if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
|
||||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
|
||||||
}
|
|
||||||
const reason: ConnectorLeaseRejectReason =
|
|
||||||
current && (current.releasedAt || current.expiresAt <= new Date(input.now))
|
|
||||||
? 'takeover_required'
|
|
||||||
: 'lease_held';
|
|
||||||
await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason));
|
|
||||||
return { ok: false, reason };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return unwrap(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease> {
|
|
||||||
const result: MutationResult = await this.db.transaction(
|
|
||||||
async (tx): Promise<MutationResult> => {
|
|
||||||
const current = await findRow(tx, input);
|
|
||||||
if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) {
|
|
||||||
await insertAudit(
|
|
||||||
tx,
|
|
||||||
rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'),
|
|
||||||
);
|
|
||||||
return { ok: false, reason: 'cas_mismatch' };
|
|
||||||
}
|
|
||||||
if (current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
|
||||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
|
||||||
}
|
|
||||||
const updated = await tx
|
|
||||||
.update(logicalAgentConnectorLeases)
|
|
||||||
.set({
|
|
||||||
leaseId: input.leaseId,
|
|
||||||
connectorId: input.connectorId,
|
|
||||||
scopes: [...input.scopes],
|
|
||||||
leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`,
|
|
||||||
acquiredAt: new Date(input.now),
|
|
||||||
heartbeatAt: new Date(input.now),
|
|
||||||
expiresAt: new Date(input.expiresAt),
|
|
||||||
releasedAt: null,
|
|
||||||
updatedAt: new Date(input.now),
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
bindingPredicate(input),
|
|
||||||
eq(logicalAgentConnectorLeases.leaseId, current.leaseId),
|
|
||||||
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
const row = updated[0];
|
|
||||||
if (!row) {
|
|
||||||
await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch'));
|
|
||||||
return { ok: false, reason: 'cas_mismatch' };
|
|
||||||
}
|
|
||||||
const lease = toLease(row);
|
|
||||||
await insertAudit(tx, lifecycleAudit(input, lease, 'takeover'));
|
|
||||||
return { ok: true, lease };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return unwrap(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease> {
|
|
||||||
const result: MutationResult = await this.db.transaction(
|
|
||||||
async (tx): Promise<MutationResult> => {
|
|
||||||
const updated = await tx
|
|
||||||
.update(logicalAgentConnectorLeases)
|
|
||||||
.set({
|
|
||||||
heartbeatAt: new Date(input.now),
|
|
||||||
expiresAt: new Date(input.expiresAt),
|
|
||||||
updatedAt: new Date(input.now),
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
bindingPredicate(input.lease),
|
|
||||||
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
|
||||||
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
|
||||||
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
|
||||||
isNull(logicalAgentConnectorLeases.releasedAt),
|
|
||||||
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
const row = updated[0];
|
|
||||||
if (row) {
|
|
||||||
const lease = toLease(row);
|
|
||||||
await insertAudit(tx, lifecycleAudit(input, lease, 'renew'));
|
|
||||||
return { ok: true, lease };
|
|
||||||
}
|
|
||||||
const current = await findRow(tx, input.lease);
|
|
||||||
const reason = classifyAuthorityFailure(
|
|
||||||
current ? toLease(current) : null,
|
|
||||||
input.lease,
|
|
||||||
input.now,
|
|
||||||
);
|
|
||||||
if (reason === 'lease_expired' && current) {
|
|
||||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
|
||||||
}
|
|
||||||
await insertAudit(
|
|
||||||
tx,
|
|
||||||
rejectionAudit(
|
|
||||||
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
|
||||||
current ? toLease(current) : null,
|
|
||||||
reason,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return { ok: false, reason };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return unwrap(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async release(input: ConnectorLeaseReleaseMutation): Promise<void> {
|
|
||||||
const result: MutationResult = await this.db.transaction(
|
|
||||||
async (tx): Promise<MutationResult> => {
|
|
||||||
const updated = await tx
|
|
||||||
.update(logicalAgentConnectorLeases)
|
|
||||||
.set({
|
|
||||||
releasedAt: new Date(input.now),
|
|
||||||
expiresAt: new Date(input.now),
|
|
||||||
updatedAt: new Date(input.now),
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
bindingPredicate(input.lease),
|
|
||||||
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
|
||||||
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
|
||||||
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
|
||||||
isNull(logicalAgentConnectorLeases.releasedAt),
|
|
||||||
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
const row = updated[0];
|
|
||||||
if (row) {
|
|
||||||
const lease = toLease(row);
|
|
||||||
await insertAudit(tx, lifecycleAudit(input, lease, 'release'));
|
|
||||||
return { ok: true, lease };
|
|
||||||
}
|
|
||||||
const current = await findRow(tx, input.lease);
|
|
||||||
const reason = classifyAuthorityFailure(
|
|
||||||
current ? toLease(current) : null,
|
|
||||||
input.lease,
|
|
||||||
input.now,
|
|
||||||
);
|
|
||||||
if (reason === 'lease_expired' && current) {
|
|
||||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
|
||||||
}
|
|
||||||
await insertAudit(
|
|
||||||
tx,
|
|
||||||
rejectionAudit(
|
|
||||||
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
|
||||||
current ? toLease(current) : null,
|
|
||||||
reason,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return { ok: false, reason };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
unwrap(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
|
|
||||||
const row = await findRow(this.db, binding);
|
|
||||||
return row ? toLease(row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
|
|
||||||
await insertAudit(this.db, event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrap(result: MutationResult): ConnectorLease {
|
|
||||||
if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason));
|
|
||||||
return result.lease;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeErrorMessage(reason: ConnectorLeaseRejectReason): string {
|
|
||||||
return `Connector lease mutation denied: ${reason}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindingPredicate(binding: LogicalAgentBinding) {
|
|
||||||
return and(
|
|
||||||
eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId),
|
|
||||||
eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId),
|
|
||||||
eq(logicalAgentConnectorLeases.bindingId, binding.bindingId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findRow(
|
|
||||||
db: Pick<Db, 'select'>,
|
|
||||||
binding: LogicalAgentBinding,
|
|
||||||
): Promise<typeof logicalAgentConnectorLeases.$inferSelect | null> {
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(logicalAgentConnectorLeases)
|
|
||||||
.where(bindingPredicate(binding))
|
|
||||||
.limit(1);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease {
|
|
||||||
return Object.freeze({
|
|
||||||
identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }),
|
|
||||||
bindingId: row.bindingId,
|
|
||||||
leaseId: row.leaseId,
|
|
||||||
connectorId: row.connectorId,
|
|
||||||
scopes: Object.freeze([...row.scopes]),
|
|
||||||
leaseEpoch: row.leaseEpoch.toString(10),
|
|
||||||
acquiredAt: row.acquiredAt.toISOString(),
|
|
||||||
heartbeatAt: row.heartbeatAt.toISOString(),
|
|
||||||
expiresAt: row.expiresAt.toISOString(),
|
|
||||||
...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function classifyAuthorityFailure(
|
|
||||||
current: ConnectorLease | null,
|
|
||||||
claimed: ConnectorLease,
|
|
||||||
now: string,
|
|
||||||
): ConnectorLeaseRejectReason {
|
|
||||||
if (!current) return 'lease_missing';
|
|
||||||
if (current.releasedAt) return 'lease_released';
|
|
||||||
if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired';
|
|
||||||
if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch';
|
|
||||||
return 'connector_mismatch';
|
|
||||||
}
|
|
||||||
|
|
||||||
function lifecycleAudit(
|
|
||||||
input: { readonly correlationId: string; readonly now: string },
|
|
||||||
lease: ConnectorLease,
|
|
||||||
event: Exclude<ConnectorLeaseAuditEvent['event'], 'reject'>,
|
|
||||||
): ConnectorLeaseAuditEvent {
|
|
||||||
return {
|
|
||||||
identity: lease.identity,
|
|
||||||
bindingId: lease.bindingId,
|
|
||||||
connectorId: lease.connectorId,
|
|
||||||
leaseId: lease.leaseId,
|
|
||||||
leaseEpoch: lease.leaseEpoch,
|
|
||||||
event,
|
|
||||||
outcome: 'succeeded',
|
|
||||||
correlationId: input.correlationId,
|
|
||||||
occurredAt: input.now,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function rejectionAudit(
|
|
||||||
input: {
|
|
||||||
readonly identity: ConnectorLease['identity'];
|
|
||||||
readonly bindingId: string;
|
|
||||||
readonly connectorId: string;
|
|
||||||
readonly correlationId: string;
|
|
||||||
readonly now: string;
|
|
||||||
},
|
|
||||||
current: ConnectorLease | null,
|
|
||||||
reason: ConnectorLeaseRejectReason,
|
|
||||||
): ConnectorLeaseAuditEvent {
|
|
||||||
return {
|
|
||||||
identity: input.identity,
|
|
||||||
bindingId: input.bindingId,
|
|
||||||
connectorId: input.connectorId,
|
|
||||||
event: 'reject',
|
|
||||||
outcome: 'denied',
|
|
||||||
correlationId: input.correlationId,
|
|
||||||
occurredAt: input.now,
|
|
||||||
...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}),
|
|
||||||
reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function insertAudit(db: Pick<Db, 'insert'>, event: ConnectorLeaseAuditEvent): Promise<void> {
|
|
||||||
await db.insert(connectorLeaseAuditLog).values({
|
|
||||||
tenantId: event.identity.tenantId,
|
|
||||||
logicalAgentId: event.identity.logicalAgentId,
|
|
||||||
bindingId: event.bindingId,
|
|
||||||
connectorId: event.connectorId,
|
|
||||||
...(event.leaseId ? { leaseId: event.leaseId } : {}),
|
|
||||||
...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}),
|
|
||||||
event: event.event,
|
|
||||||
outcome: event.outcome,
|
|
||||||
...(event.reason ? { reason: event.reason } : {}),
|
|
||||||
correlationId: event.correlationId,
|
|
||||||
occurredAt: new Date(event.occurredAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { ConnectorLeaseCoordinator, normalizeConnectorLease } from '@mosaicstack/agent';
|
|
||||||
import {
|
|
||||||
normalizeConnectorId,
|
|
||||||
normalizeConnectorScopes,
|
|
||||||
normalizeCorrelationId,
|
|
||||||
normalizeLogicalAgentIdentity,
|
|
||||||
normalizeLogicalBindingId,
|
|
||||||
type AcquireConnectorLeaseInput,
|
|
||||||
type ConnectorExecutionGrant,
|
|
||||||
type ConnectorLease,
|
|
||||||
type ConnectorLeaseAuditEvent,
|
|
||||||
type FencedConnectorAdapter,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
|
||||||
|
|
||||||
export const CONNECTOR_LEASE_POLICY = Symbol('CONNECTOR_LEASE_POLICY');
|
|
||||||
|
|
||||||
export type ConnectorLeasePolicyAction =
|
|
||||||
| 'lease.acquire'
|
|
||||||
| 'lease.takeover'
|
|
||||||
| 'lease.heartbeat'
|
|
||||||
| 'lease.release'
|
|
||||||
| 'lease.read'
|
|
||||||
| 'grant.issue';
|
|
||||||
|
|
||||||
export interface ConnectorLeaseRequestContext {
|
|
||||||
readonly actorScope: ActorTenantScope;
|
|
||||||
readonly correlationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GatewayConnectorLeaseRequest {
|
|
||||||
readonly logicalAgentId: string;
|
|
||||||
readonly bindingId: string;
|
|
||||||
readonly connectorId: string;
|
|
||||||
readonly scopes: readonly string[];
|
|
||||||
readonly ttlMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GatewayConnectorLeaseTakeoverRequest extends GatewayConnectorLeaseRequest {
|
|
||||||
readonly expectedEpoch: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GatewayConnectorGrantRequest {
|
|
||||||
readonly lease: ConnectorLease;
|
|
||||||
readonly scopes: readonly string[];
|
|
||||||
readonly ttlMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectorLeasePolicySubject {
|
|
||||||
readonly action: ConnectorLeasePolicyAction;
|
|
||||||
readonly actorId: string;
|
|
||||||
readonly tenantId: string;
|
|
||||||
readonly logicalAgentId: string;
|
|
||||||
readonly bindingId: string;
|
|
||||||
readonly connectorId: string;
|
|
||||||
readonly requestedScopes: readonly string[];
|
|
||||||
readonly requestedTtlMs: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectorLeasePolicy {
|
|
||||||
authorize(subject: ConnectorLeasePolicySubject): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** M1 has no concrete cutover policy: unconfigured production use fails closed. */
|
|
||||||
@Injectable()
|
|
||||||
export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy {
|
|
||||||
async authorize(_subject: ConnectorLeasePolicySubject): Promise<boolean> {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gateway-owned policy surface for durable connector authority and fenced effects. */
|
|
||||||
@Injectable()
|
|
||||||
export class ConnectorLeaseService {
|
|
||||||
private readonly coordinator: ConnectorLeaseCoordinator;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(ConnectorLeaseRepository) private readonly repository: ConnectorLeaseRepository,
|
|
||||||
@Inject(CONNECTOR_LEASE_POLICY) private readonly policy: ConnectorLeasePolicy,
|
|
||||||
) {
|
|
||||||
this.coordinator = new ConnectorLeaseCoordinator(repository);
|
|
||||||
}
|
|
||||||
|
|
||||||
async acquire(
|
|
||||||
request: GatewayConnectorLeaseRequest,
|
|
||||||
context: ConnectorLeaseRequestContext,
|
|
||||||
): Promise<ConnectorLease> {
|
|
||||||
const command = this.command(request, context);
|
|
||||||
await this.assertPolicy('lease.acquire', command, context, command.scopes, command.ttlMs);
|
|
||||||
return this.coordinator.acquire({ ...command, correlationId: this.correlation(context) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async takeover(
|
|
||||||
request: GatewayConnectorLeaseTakeoverRequest,
|
|
||||||
context: ConnectorLeaseRequestContext,
|
|
||||||
): Promise<ConnectorLease> {
|
|
||||||
const command = this.command(request, context);
|
|
||||||
await this.assertPolicy('lease.takeover', command, context, command.scopes, command.ttlMs);
|
|
||||||
return this.coordinator.takeover({
|
|
||||||
...command,
|
|
||||||
expectedEpoch: request.expectedEpoch,
|
|
||||||
correlationId: this.correlation(context),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async heartbeat(
|
|
||||||
lease: ConnectorLease,
|
|
||||||
ttlMs: number,
|
|
||||||
context: ConnectorLeaseRequestContext,
|
|
||||||
): Promise<ConnectorLease> {
|
|
||||||
const normalizedLease = normalizeConnectorLease(lease);
|
|
||||||
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
|
|
||||||
await this.assertPolicy('lease.heartbeat', durableLease, context, durableLease.scopes, ttlMs);
|
|
||||||
return this.coordinator.heartbeat({
|
|
||||||
lease: durableLease,
|
|
||||||
ttlMs,
|
|
||||||
correlationId: this.correlation(context),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async release(lease: ConnectorLease, context: ConnectorLeaseRequestContext): Promise<void> {
|
|
||||||
const normalizedLease = normalizeConnectorLease(lease);
|
|
||||||
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
|
|
||||||
await this.assertPolicy('lease.release', durableLease, context, durableLease.scopes, null);
|
|
||||||
await this.coordinator.release({
|
|
||||||
lease: durableLease,
|
|
||||||
correlationId: this.correlation(context),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async current(
|
|
||||||
logicalAgentId: string,
|
|
||||||
bindingId: string,
|
|
||||||
context: ConnectorLeaseRequestContext,
|
|
||||||
): Promise<ConnectorLease | null> {
|
|
||||||
const binding = {
|
|
||||||
identity: normalizeLogicalAgentIdentity({
|
|
||||||
tenantId: context.actorScope.tenantId,
|
|
||||||
logicalAgentId,
|
|
||||||
}),
|
|
||||||
bindingId: normalizeLogicalBindingId(bindingId),
|
|
||||||
connectorId: 'gateway',
|
|
||||||
};
|
|
||||||
await this.assertPolicy('lease.read', binding, context, [], null);
|
|
||||||
return this.coordinator.current(binding);
|
|
||||||
}
|
|
||||||
|
|
||||||
async issueGrant(
|
|
||||||
request: GatewayConnectorGrantRequest,
|
|
||||||
context: ConnectorLeaseRequestContext,
|
|
||||||
): Promise<ConnectorExecutionGrant> {
|
|
||||||
const lease = normalizeConnectorLease(request.lease);
|
|
||||||
await this.assertTenant(lease, context);
|
|
||||||
const scopes = normalizeConnectorScopes(request.scopes);
|
|
||||||
await this.assertPolicy('grant.issue', lease, context, scopes, request.ttlMs);
|
|
||||||
return this.coordinator.issueGrant({
|
|
||||||
lease,
|
|
||||||
scopes,
|
|
||||||
ttlMs: request.ttlMs,
|
|
||||||
correlationId: this.correlation(context),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async executeGrant<TInput, TOutput>(
|
|
||||||
grant: ConnectorExecutionGrant,
|
|
||||||
requiredScope: string,
|
|
||||||
input: TInput,
|
|
||||||
adapter: FencedConnectorAdapter<TInput, TOutput>,
|
|
||||||
): Promise<TOutput> {
|
|
||||||
return this.coordinator.executeGrant(grant, requiredScope, input, adapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
private command(
|
|
||||||
request: GatewayConnectorLeaseRequest,
|
|
||||||
context: ConnectorLeaseRequestContext,
|
|
||||||
): Omit<AcquireConnectorLeaseInput, 'correlationId'> {
|
|
||||||
return {
|
|
||||||
identity: normalizeLogicalAgentIdentity({
|
|
||||||
tenantId: context.actorScope.tenantId,
|
|
||||||
logicalAgentId: request.logicalAgentId,
|
|
||||||
}),
|
|
||||||
bindingId: normalizeLogicalBindingId(request.bindingId),
|
|
||||||
connectorId: normalizeConnectorId(request.connectorId),
|
|
||||||
scopes: normalizeConnectorScopes(request.scopes),
|
|
||||||
ttlMs: request.ttlMs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertTenant(
|
|
||||||
lease: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
|
||||||
context: ConnectorLeaseRequestContext,
|
|
||||||
): Promise<void> {
|
|
||||||
if (lease.identity.tenantId !== context.actorScope.tenantId) {
|
|
||||||
await this.recordPolicyDenial(
|
|
||||||
{
|
|
||||||
identity: {
|
|
||||||
tenantId: context.actorScope.tenantId,
|
|
||||||
logicalAgentId: 'untrusted',
|
|
||||||
},
|
|
||||||
bindingId: 'untrusted',
|
|
||||||
connectorId: 'untrusted',
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
throw new ForbiddenException('Connector authority tenant scope denied');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async durableLifecycleLease(
|
|
||||||
submittedLease: ConnectorLease,
|
|
||||||
context: ConnectorLeaseRequestContext,
|
|
||||||
): Promise<ConnectorLease> {
|
|
||||||
await this.assertTenant(submittedLease, context);
|
|
||||||
const durableLease = await this.coordinator.current(submittedLease);
|
|
||||||
if (!durableLease || !hasSameLifecycleAuthority(submittedLease, durableLease)) {
|
|
||||||
await this.recordPolicyDenial(durableLease ?? submittedLease, context);
|
|
||||||
throw new ForbiddenException('Connector authority policy denied');
|
|
||||||
}
|
|
||||||
return durableLease;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertPolicy(
|
|
||||||
action: ConnectorLeasePolicyAction,
|
|
||||||
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
|
||||||
context: ConnectorLeaseRequestContext,
|
|
||||||
requestedScopes: readonly string[],
|
|
||||||
requestedTtlMs: number | null,
|
|
||||||
): Promise<void> {
|
|
||||||
const allowed = await this.policy.authorize({
|
|
||||||
action,
|
|
||||||
actorId: context.actorScope.userId,
|
|
||||||
tenantId: subject.identity.tenantId,
|
|
||||||
logicalAgentId: subject.identity.logicalAgentId,
|
|
||||||
bindingId: subject.bindingId,
|
|
||||||
connectorId: subject.connectorId,
|
|
||||||
requestedScopes: Object.freeze([...requestedScopes]),
|
|
||||||
requestedTtlMs,
|
|
||||||
});
|
|
||||||
if (!allowed) {
|
|
||||||
await this.recordPolicyDenial(subject, context);
|
|
||||||
throw new ForbiddenException('Connector authority policy denied');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async recordPolicyDenial(
|
|
||||||
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
|
||||||
context: ConnectorLeaseRequestContext,
|
|
||||||
): Promise<void> {
|
|
||||||
const event: ConnectorLeaseAuditEvent = {
|
|
||||||
identity: subject.identity,
|
|
||||||
bindingId: subject.bindingId,
|
|
||||||
connectorId: subject.connectorId,
|
|
||||||
event: 'reject',
|
|
||||||
outcome: 'denied',
|
|
||||||
reason: 'policy_denied',
|
|
||||||
correlationId: this.correlation(context),
|
|
||||||
occurredAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await this.repository.recordAudit(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private correlation(context: ConnectorLeaseRequestContext): string {
|
|
||||||
return normalizeCorrelationId(context.correlationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSameLifecycleAuthority(
|
|
||||||
submittedLease: ConnectorLease,
|
|
||||||
durableLease: ConnectorLease,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
submittedLease.identity.tenantId === durableLease.identity.tenantId &&
|
|
||||||
submittedLease.identity.logicalAgentId === durableLease.identity.logicalAgentId &&
|
|
||||||
submittedLease.bindingId === durableLease.bindingId &&
|
|
||||||
submittedLease.leaseId === durableLease.leaseId &&
|
|
||||||
submittedLease.connectorId === durableLease.connectorId &&
|
|
||||||
submittedLease.leaseEpoch === durableLease.leaseEpoch &&
|
|
||||||
submittedLease.scopes.length === durableLease.scopes.length &&
|
|
||||||
submittedLease.scopes.every((scope: string, index: number): boolean => {
|
|
||||||
return scope === durableLease.scopes[index];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
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())
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { 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' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
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' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -107,7 +107,8 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
|
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
|
||||||
*/
|
*/
|
||||||
private startHealthCheckScheduler(): void {
|
private startHealthCheckScheduler(): void {
|
||||||
const intervalSecs = this.effectiveHealthCheckIntervalSecs();
|
const intervalSecs =
|
||||||
|
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS;
|
||||||
const intervalMs = intervalSecs * 1000;
|
const intervalMs = intervalSecs * 1000;
|
||||||
|
|
||||||
// Run an initial check immediately (non-blocking)
|
// Run an initial check immediately (non-blocking)
|
||||||
@@ -175,28 +176,6 @@ 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
|
// Adapter-pattern API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -33,20 +33,7 @@ export class ProvidersController {
|
|||||||
|
|
||||||
@Get('health')
|
@Get('health')
|
||||||
health() {
|
health() {
|
||||||
return { providers: this.safeProviderHealth() };
|
return { providers: this.providerService.getProvidersHealth() };
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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')
|
@Post('test')
|
||||||
@@ -64,13 +51,6 @@ export class ProvidersController {
|
|||||||
return this.routingService.rank(criteria);
|
return this.routingService.rank(criteria);
|
||||||
}
|
}
|
||||||
|
|
||||||
private safeProviderHealth() {
|
|
||||||
return this.providerService.getProvidersHealth().map(({ error, ...provider }) => ({
|
|
||||||
...provider,
|
|
||||||
...(error ? { errorCode: 'provider_unavailable' } : {}),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Credential CRUD ──────────────────────────────────────────────────────
|
// ── Credential CRUD ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,500 +0,0 @@
|
|||||||
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 } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.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 { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
|
|
||||||
@Controller('api/sessions')
|
@Controller('api/sessions')
|
||||||
@@ -20,24 +18,23 @@ export class SessionsController {
|
|||||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@CurrentUser() user: AuthenticatedUserLike) {
|
list() {
|
||||||
const sessions = this.agentService.listSessions(scopeFromUser(user));
|
const sessions = this.agentService.listSessions();
|
||||||
return { sessions, total: sessions.length };
|
return { sessions, total: sessions.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
findOne(@Param('id') id: string) {
|
||||||
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
|
const info = this.agentService.getSessionInfo(id);
|
||||||
if (!info) throw new NotFoundException('Session not found');
|
if (!info) throw new NotFoundException('Session not found');
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
async destroy(@Param('id') id: string) {
|
||||||
const scope = scopeFromUser(user);
|
const info = this.agentService.getSessionInfo(id);
|
||||||
const info = this.agentService.getSessionInfo(id, scope);
|
|
||||||
if (!info) throw new NotFoundException('Session not found');
|
if (!info) throw new NotFoundException('Session not found');
|
||||||
await this.agentService.destroySession(id, scope);
|
await this.agentService.destroySession(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import type {
|
import type { Memory } from '@mosaicstack/memory';
|
||||||
EmbeddingProvider,
|
import type { EmbeddingProvider } from '@mosaicstack/memory';
|
||||||
Memory,
|
|
||||||
OperatorMemoryPlugin,
|
|
||||||
OperatorMemoryScope,
|
|
||||||
} from '@mosaicstack/memory';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create memory tools bound to the session's authenticated userId.
|
* Create memory tools bound to the session's authenticated userId.
|
||||||
@@ -17,10 +13,8 @@ import type {
|
|||||||
export function createMemoryTools(
|
export function createMemoryTools(
|
||||||
memory: Memory,
|
memory: Memory,
|
||||||
embeddingProvider: EmbeddingProvider | null,
|
embeddingProvider: EmbeddingProvider | null,
|
||||||
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
|
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
|
||||||
sessionUserId: string | undefined,
|
sessionUserId: string | undefined,
|
||||||
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
|
|
||||||
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
|
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
/** Return an error result when no session user is bound. */
|
/** Return an error result when no session user is bound. */
|
||||||
function noUserError() {
|
function noUserError() {
|
||||||
@@ -52,14 +46,6 @@ export function createMemoryTools(
|
|||||||
limit?: number;
|
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) {
|
if (!embeddingProvider) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -172,18 +158,6 @@ export function createMemoryTools(
|
|||||||
};
|
};
|
||||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
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;
|
let embedding: number[] | null = null;
|
||||||
if (embeddingProvider) {
|
if (embeddingProvider) {
|
||||||
embedding = await embeddingProvider.embed(content);
|
embedding = await embeddingProvider.embed(content);
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,7 @@ describe('Chat controller source hardening', () => {
|
|||||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||||
|
|
||||||
expect(source).toContain('@UseGuards(AuthGuard)');
|
expect(source).toContain('@UseGuards(AuthGuard)');
|
||||||
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
|
expect(source).toContain('@CurrentUser() user: { id: string }');
|
||||||
expect(source).toContain('const scope = scopeFromUser(user);');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
Logger,
|
Logger,
|
||||||
ForbiddenException,
|
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
|
||||||
Inject,
|
Inject,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -15,7 +13,6 @@ import { Throttle } from '@nestjs/throttler';
|
|||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.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 { v4 as uuid } from 'uuid';
|
||||||
import { ChatRequestDto } from './chat.dto.js';
|
import { ChatRequestDto } from './chat.dto.js';
|
||||||
|
|
||||||
@@ -35,23 +32,16 @@ export class ChatController {
|
|||||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
async chat(
|
async chat(
|
||||||
@Body() body: ChatRequestDto,
|
@Body() body: ChatRequestDto,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: { id: string },
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
const conversationId = body.conversationId ?? uuid();
|
const conversationId = body.conversationId ?? uuid();
|
||||||
const scope = scopeFromUser(user);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
let agentSession = this.agentService.getSession(conversationId);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
agentSession = await this.agentService.createSession(conversationId, {
|
agentSession = await this.agentService.createSession(conversationId);
|
||||||
userId: scope.userId,
|
|
||||||
tenantId: scope.tenantId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ForbiddenException) {
|
|
||||||
throw new NotFoundException('Session not found');
|
|
||||||
}
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Session creation failed for conversation=${conversationId}`,
|
`Session creation failed for conversation=${conversationId}`,
|
||||||
err instanceof Error ? err.stack : String(err),
|
err instanceof Error ? err.stack : String(err),
|
||||||
@@ -70,13 +60,8 @@ export class ChatController {
|
|||||||
reject(new Error('Agent response timed out'));
|
reject(new Error('Agent response timed out'));
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
const cleanup = this.agentService.onEvent(
|
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||||
conversationId,
|
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
||||||
(event: AgentSessionEvent) => {
|
|
||||||
if (
|
|
||||||
event.type === 'message_update' &&
|
|
||||||
event.assistantMessageEvent.type === 'text_delta'
|
|
||||||
) {
|
|
||||||
responseText += event.assistantMessageEvent.delta;
|
responseText += event.assistantMessageEvent.delta;
|
||||||
}
|
}
|
||||||
if (event.type === 'agent_end') {
|
if (event.type === 'agent_end') {
|
||||||
@@ -84,13 +69,11 @@ export class ChatController {
|
|||||||
cleanup();
|
cleanup();
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
scope,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.agentService.prompt(conversationId, body.content, scope);
|
await this.agentService.prompt(conversationId, body.content);
|
||||||
await done;
|
await done;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpException) throw err;
|
if (err instanceof HttpException) throw err;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
|
||||||
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
export class ChatRequestDto {
|
export class ChatRequestDto {
|
||||||
@@ -33,7 +32,4 @@ export class ChatSocketMessageDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
|
||||||
/** Validated channel attachment references; binary content is not embedded. */
|
|
||||||
attachments?: readonly ChannelAttachmentDto[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { timingSafeEqual } from 'node:crypto';
|
|
||||||
import type { IncomingHttpHeaders } from 'node:http';
|
import type { IncomingHttpHeaders } from 'node:http';
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
|
|
||||||
@@ -13,19 +12,6 @@ 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(
|
export async function validateSocketSession(
|
||||||
headers: IncomingHttpHeaders,
|
headers: IncomingHttpHeaders,
|
||||||
auth: SessionAuth,
|
auth: SessionAuth,
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
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
@@ -1,116 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -89,7 +89,6 @@ function buildService(): CommandExecutorService {
|
|||||||
describe('CommandExecutorService — P8-012 commands', () => {
|
describe('CommandExecutorService — P8-012 commands', () => {
|
||||||
let service: CommandExecutorService;
|
let service: CommandExecutorService;
|
||||||
const userId = 'user-123';
|
const userId = 'user-123';
|
||||||
const userScope = { userId, tenantId: userId };
|
|
||||||
const conversationId = 'conv-456';
|
const conversationId = 'conv-456';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -100,26 +99,31 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider login — missing provider name
|
// /provider login — missing provider name
|
||||||
it('/provider login with no provider name returns usage error', async () => {
|
it('/provider login with no provider name returns usage error', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('Usage: /provider login');
|
expect(result.message).toContain('Usage: /provider login');
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
});
|
});
|
||||||
|
|
||||||
// /provider login anthropic — no bearer token or auth URL reaches chat output
|
// /provider login anthropic — success with URL containing poll token
|
||||||
it('/provider login <name> keeps its one-time token out of chat output', async () => {
|
it('/provider login <name> returns success with URL and poll token', async () => {
|
||||||
const payload: SlashCommandPayload = {
|
const payload: SlashCommandPayload = {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
args: 'login anthropic',
|
args: 'login anthropic',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
expect(result.message).toContain('anthropic');
|
expect(result.message).toContain('anthropic');
|
||||||
expect(result.message).not.toContain('http');
|
expect(result.message).toContain('http');
|
||||||
expect(result.message).not.toContain('token=');
|
// data should contain loginUrl and pollToken
|
||||||
expect(result.data).toEqual({ provider: 'anthropic' });
|
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);
|
||||||
// Verify Valkey was called
|
// Verify Valkey was called
|
||||||
expect(mockRedis.set).toHaveBeenCalledOnce();
|
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||||
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||||
@@ -134,7 +138,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider with no args — returns usage
|
// /provider with no args — returns usage
|
||||||
it('/provider with no args returns usage message', async () => {
|
it('/provider with no args returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage: /provider');
|
expect(result.message).toContain('Usage: /provider');
|
||||||
});
|
});
|
||||||
@@ -142,7 +146,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider list
|
// /provider list
|
||||||
it('/provider list returns success', async () => {
|
it('/provider list returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
});
|
});
|
||||||
@@ -150,7 +154,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider logout with no name — usage error
|
// /provider logout with no name — usage error
|
||||||
it('/provider logout with no name returns error', async () => {
|
it('/provider logout with no name returns error', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('Usage: /provider logout');
|
expect(result.message).toContain('Usage: /provider logout');
|
||||||
});
|
});
|
||||||
@@ -162,7 +166,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'unknown',
|
args: 'unknown',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('Unknown subcommand');
|
expect(result.message).toContain('Unknown subcommand');
|
||||||
});
|
});
|
||||||
@@ -170,7 +174,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /mission status
|
// /mission status
|
||||||
it('/mission status returns stub message', async () => {
|
it('/mission status returns stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('mission');
|
expect(result.command).toBe('mission');
|
||||||
expect(result.message).toContain('Mission status');
|
expect(result.message).toContain('Mission status');
|
||||||
@@ -179,7 +183,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /mission with no args
|
// /mission with no args
|
||||||
it('/mission with no args returns status stub', async () => {
|
it('/mission with no args returns status stub', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Mission status');
|
expect(result.message).toContain('Mission status');
|
||||||
});
|
});
|
||||||
@@ -191,7 +195,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'set my-mission-123',
|
args: 'set my-mission-123',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('my-mission-123');
|
expect(result.message).toContain('my-mission-123');
|
||||||
});
|
});
|
||||||
@@ -199,7 +203,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /agent list
|
// /agent list
|
||||||
it('/agent list returns stub message', async () => {
|
it('/agent list returns stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('agent');
|
expect(result.command).toBe('agent');
|
||||||
expect(result.message).toContain('agent');
|
expect(result.message).toContain('agent');
|
||||||
@@ -208,7 +212,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /agent with no args
|
// /agent with no args
|
||||||
it('/agent with no args returns usage', async () => {
|
it('/agent with no args returns usage', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage: /agent');
|
expect(result.message).toContain('Usage: /agent');
|
||||||
});
|
});
|
||||||
@@ -220,7 +224,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'my-agent-id',
|
args: 'my-agent-id',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('my-agent-id');
|
expect(result.message).toContain('my-agent-id');
|
||||||
});
|
});
|
||||||
@@ -228,7 +232,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /prdy
|
// /prdy
|
||||||
it('/prdy returns PRD wizard message', async () => {
|
it('/prdy returns PRD wizard message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('prdy');
|
expect(result.command).toBe('prdy');
|
||||||
expect(result.message).toContain('mosaic prdy');
|
expect(result.message).toContain('mosaic prdy');
|
||||||
@@ -237,7 +241,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /tools
|
// /tools
|
||||||
it('/tools returns tools stub message', async () => {
|
it('/tools returns tools stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||||
const result = await service.execute(payload, userScope);
|
const result = await service.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('tools');
|
expect(result.command).toBe('tools');
|
||||||
expect(result.message).toContain('tools');
|
expect(result.message).toContain('tools');
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,6 @@ import type { QueueHandle } from '@mosaicstack/queue';
|
|||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
@@ -11,7 +10,6 @@ import { ReloadService } from '../reload/reload.service.js';
|
|||||||
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -34,17 +32,10 @@ export class CommandExecutorService {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@Inject(McpClientService)
|
@Inject(McpClientService)
|
||||||
private readonly mcpClient: McpClientService | null,
|
private readonly mcpClient: McpClientService | null,
|
||||||
@Optional()
|
|
||||||
@Inject(CommandAuthorizationService)
|
|
||||||
private readonly authorization: CommandAuthorizationService | null = null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||||
payload: SlashCommandPayload,
|
|
||||||
scope: ActorTenantScope,
|
|
||||||
): Promise<SlashCommandResultPayload> {
|
|
||||||
const { command, args, conversationId } = payload;
|
const { command, args, conversationId } = payload;
|
||||||
const userId = scope.userId;
|
|
||||||
|
|
||||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||||
if (!def) {
|
if (!def) {
|
||||||
@@ -56,24 +47,14 @@ 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 {
|
try {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'model':
|
case 'model':
|
||||||
return await this.handleModel(args ?? null, conversationId, scope);
|
return await this.handleModel(args ?? null, conversationId);
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
return await this.handleThinking(args ?? null, conversationId);
|
return await this.handleThinking(args ?? null, conversationId);
|
||||||
case 'system':
|
case 'system':
|
||||||
return await this.handleSystem(args ?? null, conversationId, scope);
|
return await this.handleSystem(args ?? null, conversationId);
|
||||||
case 'new':
|
case 'new':
|
||||||
return {
|
return {
|
||||||
command,
|
command,
|
||||||
@@ -102,17 +83,18 @@ export class CommandExecutorService {
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Retry last message requested.',
|
message: 'Retry last message requested.',
|
||||||
};
|
};
|
||||||
case 'gc':
|
case 'gc': {
|
||||||
// Global retention requires a separate, authorized and audited job.
|
// Admin-only: system-wide GC sweep across all sessions
|
||||||
// Session cleanup is performed only through the session lifecycle.
|
const result = await this.sessionGC.sweepOrphans();
|
||||||
return {
|
return {
|
||||||
command: 'gc',
|
command: 'gc',
|
||||||
success: false,
|
success: true,
|
||||||
message: 'Global GC is disabled pending an authorized retention job.',
|
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case 'agent':
|
case 'agent':
|
||||||
return await this.handleAgent(args ?? null, conversationId, scope);
|
return await this.handleAgent(args ?? null, conversationId, userId);
|
||||||
case 'provider':
|
case 'provider':
|
||||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||||
case 'mission':
|
case 'mission':
|
||||||
@@ -161,22 +143,13 @@ 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(
|
private async handleModel(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
scope: ActorTenantScope,
|
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!args || args.trim().length === 0) {
|
if (!args || args.trim().length === 0) {
|
||||||
// Show current override or usage hint
|
// Show current override or usage hint
|
||||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
|
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
|
||||||
if (currentOverride) {
|
if (currentOverride) {
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
@@ -198,7 +171,7 @@ export class CommandExecutorService {
|
|||||||
|
|
||||||
// /model clear removes the override and re-enables automatic routing
|
// /model clear removes the override and re-enables automatic routing
|
||||||
if (modelName === 'clear') {
|
if (modelName === 'clear') {
|
||||||
this.chatGateway?.setModelOverride(conversationId, null, scope);
|
this.chatGateway?.setModelOverride(conversationId, null);
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -208,9 +181,9 @@ export class CommandExecutorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set the sticky per-session override (M4-007)
|
// Set the sticky per-session override (M4-007)
|
||||||
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
|
this.chatGateway?.setModelOverride(conversationId, modelName);
|
||||||
|
|
||||||
const session = this.agentService.getSession(conversationId, scope);
|
const session = this.agentService.getSession(conversationId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
@@ -251,11 +224,10 @@ export class CommandExecutorService {
|
|||||||
private async handleSystem(
|
private async handleSystem(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
scope: ActorTenantScope,
|
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!args || args.trim().length === 0) {
|
if (!args || args.trim().length === 0) {
|
||||||
// Clear the override when called with no args
|
// Clear the override when called with no args
|
||||||
await this.systemOverride.clear(conversationId, scope);
|
await this.systemOverride.clear(conversationId);
|
||||||
return {
|
return {
|
||||||
command: 'system',
|
command: 'system',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -264,7 +236,7 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.systemOverride.set(conversationId, args.trim(), scope);
|
await this.systemOverride.set(conversationId, args.trim());
|
||||||
return {
|
return {
|
||||||
command: 'system',
|
command: 'system',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -276,9 +248,8 @@ export class CommandExecutorService {
|
|||||||
private async handleAgent(
|
private async handleAgent(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
scope: ActorTenantScope,
|
userId: string,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
const userId = scope.userId;
|
|
||||||
if (!args) {
|
if (!args) {
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
@@ -367,14 +338,11 @@ export class CommandExecutorService {
|
|||||||
conversationId,
|
conversationId,
|
||||||
agentConfig.id,
|
agentConfig.id,
|
||||||
agentConfig.name,
|
agentConfig.name,
|
||||||
scope,
|
|
||||||
agentConfig.model ?? undefined,
|
agentConfig.model ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
||||||
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
|
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
|
||||||
agentName: agentConfig.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
||||||
@@ -435,28 +403,22 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const pollToken = crypto.randomUUID();
|
const pollToken = crypto.randomUUID();
|
||||||
const tokenDigest = await crypto.subtle.digest(
|
const key = `mosaic:auth:poll:${pollToken}`;
|
||||||
'SHA-256',
|
// Store pending state in Valkey (TTL 5 minutes)
|
||||||
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(
|
await this.redis.set(
|
||||||
key,
|
key,
|
||||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
'EX',
|
'EX',
|
||||||
300,
|
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 {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
|
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
data: { provider: providerName },
|
data: { loginUrl, pollToken, provider: providerName },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
let registry: CommandRegistryService;
|
let registry: CommandRegistryService;
|
||||||
let executor: CommandExecutorService;
|
let executor: CommandExecutorService;
|
||||||
const userId = 'user-integ-001';
|
const userId = 'user-integ-001';
|
||||||
const userScope = { userId, tenantId: userId };
|
|
||||||
const conversationId = 'conv-integ-001';
|
const conversationId = 'conv-integ-001';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -171,26 +170,28 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// Unknown command returns error
|
// Unknown command returns error
|
||||||
it('unknown command returns success:false with descriptive message', async () => {
|
it('unknown command returns success:false with descriptive message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('nonexistent');
|
expect(result.message).toContain('nonexistent');
|
||||||
expect(result.command).toBe('nonexistent');
|
expect(result.command).toBe('nonexistent');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/gc refuses an unaudited global sweep', async () => {
|
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
|
||||||
|
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled();
|
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('disabled pending an authorized retention job');
|
expect(result.message).toContain('GC sweep complete');
|
||||||
|
expect(result.message).toContain('3 orphaned sessions');
|
||||||
});
|
});
|
||||||
|
|
||||||
// /system with args calls SystemOverrideService.set
|
// /system with args calls SystemOverrideService.set
|
||||||
it('/system with text calls SystemOverrideService.set', async () => {
|
it('/system with text calls SystemOverrideService.set', async () => {
|
||||||
const override = 'You are a helpful assistant.';
|
const override = 'You are a helpful assistant.';
|
||||||
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
|
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('override set');
|
expect(result.message).toContain('override set');
|
||||||
});
|
});
|
||||||
@@ -198,8 +199,8 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /system with no args clears the override
|
// /system with no args clears the override
|
||||||
it('/system with no args calls SystemOverrideService.clear', async () => {
|
it('/system with no args calls SystemOverrideService.clear', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
|
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('cleared');
|
expect(result.message).toContain('cleared');
|
||||||
});
|
});
|
||||||
@@ -211,7 +212,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
args: 'claude-3-opus',
|
args: 'claude-3-opus',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('model');
|
expect(result.command).toBe('model');
|
||||||
expect(result.message).toContain('claude-3-opus');
|
expect(result.message).toContain('claude-3-opus');
|
||||||
@@ -220,7 +221,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /thinking with valid level returns success
|
// /thinking with valid level returns success
|
||||||
it('/thinking with valid level returns success', async () => {
|
it('/thinking with valid level returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('high');
|
expect(result.message).toContain('high');
|
||||||
});
|
});
|
||||||
@@ -228,7 +229,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /thinking with invalid level returns usage message
|
// /thinking with invalid level returns usage message
|
||||||
it('/thinking with invalid level returns usage message', async () => {
|
it('/thinking with invalid level returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage:');
|
expect(result.message).toContain('Usage:');
|
||||||
});
|
});
|
||||||
@@ -236,7 +237,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /new command returns success
|
// /new command returns success
|
||||||
it('/new returns success', async () => {
|
it('/new returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('new');
|
expect(result.command).toBe('new');
|
||||||
});
|
});
|
||||||
@@ -244,7 +245,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /reload without reloadService returns failure
|
// /reload without reloadService returns failure
|
||||||
it('/reload without ReloadService returns failure', async () => {
|
it('/reload without ReloadService returns failure', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('ReloadService');
|
expect(result.message).toContain('ReloadService');
|
||||||
});
|
});
|
||||||
@@ -254,7 +255,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
for (const cmd of stubCommands) {
|
for (const cmd of stubCommands) {
|
||||||
it(`/${cmd} returns success (stub)`, async () => {
|
it(`/${cmd} returns success (stub)`, async () => {
|
||||||
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
||||||
const result = await executor.execute(payload, userScope);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe(cmd);
|
expect(result.command).toBe(cmd);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
|||||||
import { ChatModule } from '../chat/chat.module.js';
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
|
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
|
||||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||||
@@ -26,16 +24,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
CommandAuthorizationService,
|
|
||||||
CommandRuntimeApprovalVerifier,
|
|
||||||
CommandExecutorService,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
CommandRegistryService,
|
|
||||||
CommandAuthorizationService,
|
|
||||||
CommandRuntimeApprovalVerifier,
|
|
||||||
CommandExecutorService,
|
CommandExecutorService,
|
||||||
],
|
],
|
||||||
|
exports: [CommandRegistryService, CommandExecutorService],
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
|
|
||||||
import { CoordService } from './coord.service.js';
|
import { CoordService } from './coord.service.js';
|
||||||
import { CoordController } from './coord.controller.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({
|
@Module({
|
||||||
providers: [
|
providers: [CoordService],
|
||||||
CoordService,
|
controllers: [CoordController],
|
||||||
{
|
exports: [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 {}
|
export class CoordModule {}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
|
||||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: AUTH,
|
|
||||||
useValue: {
|
|
||||||
api: {
|
|
||||||
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
|
|
||||||
headers.get('cookie') === 'session=trusted'
|
|
||||||
? { user: { id: 'operator-1', tenantId: 'tenant-1' }, session: { id: 'session-1' } }
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AuthGuard,
|
|
||||||
],
|
|
||||||
exports: [AUTH, AuthGuard],
|
|
||||||
})
|
|
||||||
class AuthenticatedRequestModule {}
|
|
||||||
|
|
||||||
describe('InteractionCoordinationController route aliases', (): void => {
|
|
||||||
let app: NestFastifyApplication | undefined;
|
|
||||||
const coordination = {
|
|
||||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
|
||||||
observe: vi.fn(async () => ({ status: 'running' })),
|
|
||||||
result: vi.fn(async () => ({ status: 'completed' })),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeAll(async (): Promise<void> => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [AuthenticatedRequestModule],
|
|
||||||
controllers: [InteractionCoordinationController],
|
|
||||||
providers: [{ provide: InteractionCoordinationService, useValue: coordination }],
|
|
||||||
}).compile();
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
afterAll(async (): Promise<void> => app?.close());
|
|
||||||
|
|
||||||
it('routes handoff, observe, and result through the same AuthGuard-protected service for both prefixes', async (): Promise<void> => {
|
|
||||||
if (!app) throw new Error('test app was not initialized');
|
|
||||||
for (const prefix of ['/api/coord/interaction', '/api/coord/mos']) {
|
|
||||||
const headers = { cookie: 'session=trusted', 'x-correlation-id': `corr-${prefix}` };
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `${prefix}/handoff`,
|
|
||||||
headers,
|
|
||||||
payload: { idempotencyKey: `key-${prefix}`, summary: 'handoff' },
|
|
||||||
})
|
|
||||||
).statusCode,
|
|
||||||
).toBe(201);
|
|
||||||
expect(
|
|
||||||
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/observe`, headers }))
|
|
||||||
.statusCode,
|
|
||||||
).toBe(200);
|
|
||||||
expect(
|
|
||||||
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/result`, headers }))
|
|
||||||
.statusCode,
|
|
||||||
).toBe(200);
|
|
||||||
}
|
|
||||||
expect(coordination.handoff).toHaveBeenCalledTimes(2);
|
|
||||||
expect(coordination.observe).toHaveBeenCalledTimes(2);
|
|
||||||
expect(coordination.result).toHaveBeenCalledTimes(2);
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/coord/interaction/handoff',
|
|
||||||
payload: { idempotencyKey: 'denied', summary: 'x' },
|
|
||||||
})
|
|
||||||
).statusCode,
|
|
||||||
).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { Logger } from '@nestjs/common';
|
|||||||
import type { QueueHandle } from '@mosaicstack/queue';
|
import type { QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import { SessionGCService } from './session-gc.service.js';
|
import { SessionGCService } from './session-gc.service.js';
|
||||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
|
||||||
|
|
||||||
type MockRedis = {
|
type MockRedis = {
|
||||||
scan: ReturnType<typeof vi.fn>;
|
scan: ReturnType<typeof vi.fn>;
|
||||||
@@ -13,12 +12,7 @@ type MockRedis = {
|
|||||||
describe('SessionGCService', () => {
|
describe('SessionGCService', () => {
|
||||||
let service: SessionGCService;
|
let service: SessionGCService;
|
||||||
let mockRedis: MockRedis;
|
let mockRedis: MockRedis;
|
||||||
let mockLogService: {
|
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
|
||||||
logs: {
|
|
||||||
promoteSessionToWarm: ReturnType<typeof vi.fn>;
|
|
||||||
promoteToWarm: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: build a scan mock that returns all provided keys in a single
|
* Helper: build a scan mock that returns all provided keys in a single
|
||||||
@@ -36,7 +30,6 @@ describe('SessionGCService', () => {
|
|||||||
|
|
||||||
mockLogService = {
|
mockLogService = {
|
||||||
logs: {
|
logs: {
|
||||||
promoteSessionToWarm: vi.fn().mockResolvedValue(0),
|
|
||||||
promoteToWarm: vi.fn().mockResolvedValue(0),
|
promoteToWarm: vi.fn().mockResolvedValue(0),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -66,76 +59,54 @@ describe('SessionGCService', () => {
|
|||||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
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 () => {
|
it('collect() returns sessionId in result', async () => {
|
||||||
const result = await service.collect('test-session-id');
|
const result = await service.collect('test-session-id');
|
||||||
expect(result.sessionId).toBe('test-session-id');
|
expect(result.sessionId).toBe('test-session-id');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collect() demotes logs only for the requested session', async () => {
|
it('fullCollect() deletes all session keys', async () => {
|
||||||
await service.collect('owned-session');
|
mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
|
||||||
|
const result = await service.fullCollect();
|
||||||
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
expect(mockRedis.del).toHaveBeenCalled();
|
||||||
'owned-session',
|
expect(result.valkeyKeys).toBe(2);
|
||||||
expect.any(Date),
|
|
||||||
);
|
|
||||||
expect(mockLogService.logs.promoteToWarm).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose automatic global GC entry points', () => {
|
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
|
||||||
expect('fullCollect' in service).toBe(false);
|
mockRedis.scan = makeScanMock([]);
|
||||||
expect('sweepOrphans' in service).toBe(false);
|
const result = await service.fullCollect();
|
||||||
|
expect(result.valkeyKeys).toBe(0);
|
||||||
|
expect(mockRedis.del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fullCollect() returns duration', async () => {
|
||||||
|
const result = await service.fullCollect();
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweepOrphans() extracts unique session IDs and collects them', async () => {
|
||||||
|
// First scan call returns the global session list; subsequent calls return
|
||||||
|
// per-session keys during collect().
|
||||||
|
mockRedis.scan = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
'0',
|
||||||
|
['mosaic:session:abc:system', 'mosaic:session:abc:messages', 'mosaic:session:xyz:system'],
|
||||||
|
])
|
||||||
|
// collect('abc') scan
|
||||||
|
.mockResolvedValueOnce(['0', ['mosaic:session:abc:system', 'mosaic:session:abc:messages']])
|
||||||
|
// collect('xyz') scan
|
||||||
|
.mockResolvedValueOnce(['0', ['mosaic:session:xyz:system']]);
|
||||||
|
mockRedis.del.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const result = await service.sweepOrphans();
|
||||||
|
expect(result.orphanedSessions).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweepOrphans() returns empty when no session keys', async () => {
|
||||||
|
mockRedis.scan = makeScanMock([]);
|
||||||
|
const result = await service.sweepOrphans();
|
||||||
|
expect(result.orphanedSessions).toBe(0);
|
||||||
|
expect(result.totalCleaned).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||||
import type { QueueHandle } from '@mosaicstack/queue';
|
import type { QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
@@ -13,18 +13,49 @@ export interface GCResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Escape Redis glob metacharacters so a session identifier is always literal. */
|
export interface GCSweepResult {
|
||||||
function escapeRedisGlobLiteral(value: string): string {
|
orphanedSessions: number;
|
||||||
return value.replace(/[\\*?\[\]]/g, '\\$&');
|
totalCleaned: GCResult[];
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullGCResult {
|
||||||
|
valkeyKeys: number;
|
||||||
|
logsDemoted: number;
|
||||||
|
jobsPurged: number;
|
||||||
|
tempFilesRemoved: number;
|
||||||
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SessionGCService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
// Fire-and-forget: run full GC asynchronously so it does not block the
|
||||||
|
// NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms
|
||||||
|
// depending on Valkey key count; deferring it removes that latency from
|
||||||
|
// the TTFB of the first HTTP request.
|
||||||
|
this.fullCollect()
|
||||||
|
.then((result) => {
|
||||||
|
this.logger.log(
|
||||||
|
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
||||||
|
`${result.logsDemoted} logs demoted, ` +
|
||||||
|
`${result.jobsPurged} jobs purged, ` +
|
||||||
|
`${result.tempFilesRemoved} temp dirs removed ` +
|
||||||
|
`(${result.duration}ms)`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
||||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||||
@@ -48,20 +79,86 @@ export class SessionGCService {
|
|||||||
const result: GCResult = { sessionId, cleaned: {} };
|
const result: GCResult = { sessionId, cleaned: {} };
|
||||||
|
|
||||||
// 1. Valkey: delete all session-scoped keys
|
// 1. Valkey: delete all session-scoped keys
|
||||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
const pattern = `mosaic:session:${sessionId}:*`;
|
||||||
const valkeyKeys = await this.scanKeys(pattern);
|
const valkeyKeys = await this.scanKeys(pattern);
|
||||||
if (valkeyKeys.length > 0) {
|
if (valkeyKeys.length > 0) {
|
||||||
await this.redis.del(...valkeyKeys);
|
await this.redis.del(...valkeyKeys);
|
||||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. PG: demote hot-tier agent logs for this session only.
|
// 2. PG: demote hot-tier agent_logs for this session to warm
|
||||||
const cutoff = new Date();
|
const cutoff = new Date(); // demote all hot logs for this session
|
||||||
const logsDemoted = await this.logService.logs.promoteSessionToWarm(sessionId, cutoff);
|
const logsDemoted = await this.logService.logs.promoteToWarm(cutoff);
|
||||||
if (logsDemoted > 0) {
|
if (logsDemoted > 0) {
|
||||||
result.cleaned.logsDemoted = logsDemoted;
|
result.cleaned.logsDemoted = logsDemoted;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep GC — find orphaned artifacts from dead sessions.
|
||||||
|
* System-wide operation: only call from admin-authorized paths or internal
|
||||||
|
* scheduled jobs. Individual session cleanup is handled by collect().
|
||||||
|
*/
|
||||||
|
async sweepOrphans(): Promise<GCSweepResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const cleaned: GCResult[] = [];
|
||||||
|
|
||||||
|
// 1. Find all session-scoped Valkey keys (non-blocking SCAN)
|
||||||
|
const allSessionKeys = await this.scanKeys('mosaic:session:*');
|
||||||
|
|
||||||
|
// Extract unique session IDs from keys
|
||||||
|
const sessionIds = new Set<string>();
|
||||||
|
for (const key of allSessionKeys) {
|
||||||
|
const match = key.match(/^mosaic:session:([^:]+):/);
|
||||||
|
if (match) sessionIds.add(match[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. For each session ID, collect stale keys
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
const gcResult = await this.collect(sessionId);
|
||||||
|
if (Object.keys(gcResult.cleaned).length > 0) {
|
||||||
|
cleaned.push(gcResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orphanedSessions: cleaned.length,
|
||||||
|
totalCleaned: cleaned,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full GC — aggressive collection for cold start.
|
||||||
|
* Assumes no sessions survived the restart.
|
||||||
|
*/
|
||||||
|
async fullCollect(): Promise<FullGCResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
|
||||||
|
const sessionKeys = await this.scanKeys('mosaic:session:*');
|
||||||
|
if (sessionKeys.length > 0) {
|
||||||
|
await this.redis.del(...sessionKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. NOTE: channel keys are NOT collected on cold start
|
||||||
|
// (discord/telegram plugins may reconnect and resume)
|
||||||
|
|
||||||
|
// 3. PG: demote stale hot-tier logs older than 24h to warm
|
||||||
|
const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff);
|
||||||
|
|
||||||
|
// 4. No summarization job purge API available yet
|
||||||
|
const jobsPurged = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
valkeyKeys: sessionKeys.length,
|
||||||
|
logsDemoted,
|
||||||
|
jobsPurged,
|
||||||
|
tempFilesRemoved: 0,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,10 +6,4 @@ export class HealthController {
|
|||||||
check(): { status: string } {
|
check(): { status: string } {
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Readiness intentionally exposes no configuration, provider, or credential details. */
|
|
||||||
@Get('ready')
|
|
||||||
ready(): { status: string } {
|
|
||||||
return { status: 'ready' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
type OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { SummarizationService } from './summarization.service.js';
|
import { SummarizationService } from './summarization.service.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
import {
|
import {
|
||||||
QueueService,
|
QueueService,
|
||||||
QUEUE_GC,
|
|
||||||
QUEUE_SUMMARIZATION,
|
QUEUE_SUMMARIZATION,
|
||||||
|
QUEUE_GC,
|
||||||
QUEUE_TIER_MANAGEMENT,
|
QUEUE_TIER_MANAGEMENT,
|
||||||
} from '../queue/queue.service.js';
|
} from '../queue/queue.service.js';
|
||||||
import type { Worker } from 'bullmq';
|
import type { Worker } from 'bullmq';
|
||||||
@@ -22,12 +23,14 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
@Inject(QueueService) private readonly queueService: QueueService,
|
@Inject(QueueService) private readonly queueService: QueueService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||||
|
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
|
||||||
|
|
||||||
// M6-003: Summarization repeatable job
|
// M6-003: Summarization repeatable job
|
||||||
await this.queueService.addRepeatableJob(
|
await this.queueService.addRepeatableJob(
|
||||||
@@ -53,12 +56,15 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
this.registeredWorkers.push(tierWorker);
|
this.registeredWorkers.push(tierWorker);
|
||||||
|
|
||||||
// Retire any repeatable global GC schedule created by older deployments.
|
// M6-004: GC repeatable job
|
||||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
|
||||||
await this.queueService.removeRepeatableJobs(QUEUE_GC, 'session-gc');
|
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
|
||||||
|
await this.sessionGC.sweepOrphans();
|
||||||
|
});
|
||||||
|
this.registeredWorkers.push(gcWorker);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import { Logger } from '@nestjs/common';
|
|||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaicstack/auth';
|
||||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
import {
|
import type { McpService } from './mcp.service.js';
|
||||||
createMcpActorContext,
|
|
||||||
deriveMcpToolScopesForUser,
|
|
||||||
type McpService,
|
|
||||||
} from './mcp.service.js';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,25 +67,14 @@ async function handleMcpRequest(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authUser = result.user as {
|
const userId = result.user.id;
|
||||||
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 ─────────────────────────────────────────────────────
|
// ─── Session routing ─────────────────────────────────────────────────────
|
||||||
const sessionId = req.raw.headers['mcp-session-id'];
|
const sessionId = req.raw.headers['mcp-session-id'];
|
||||||
|
|
||||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||||
// Existing session request
|
// Existing session request
|
||||||
const transport = mcpService.getSession(sessionId, actor);
|
const transport = mcpService.getSession(sessionId);
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
logger.warn(`MCP session not found: ${sessionId}`);
|
logger.warn(`MCP session not found: ${sessionId}`);
|
||||||
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
@@ -127,10 +112,8 @@ async function handleMcpRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new session and handle this initializing request
|
// Create new session and handle this initializing request
|
||||||
const { transport } = mcpService.createSession(actor);
|
const { transport } = mcpService.createSession(userId);
|
||||||
logger.log(
|
logger.log(`New MCP session created for user ${userId}`);
|
||||||
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await transport.handleRequest(req.raw, reply.raw, body);
|
await transport.handleRequest(req.raw, reply.raw, body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,461 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,216 +10,11 @@ import { MEMORY } from '../memory/memory.tokens.js';
|
|||||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||||
import { CoordService } from '../coord/coord.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 {
|
interface SessionEntry {
|
||||||
server: McpServer;
|
server: McpServer;
|
||||||
transport: StreamableHTTPServerTransport;
|
transport: StreamableHTTPServerTransport;
|
||||||
createdAt: Date;
|
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;
|
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()
|
@Injectable()
|
||||||
@@ -238,18 +33,13 @@ export class McpService implements OnModuleDestroy {
|
|||||||
* Creates a new MCP session with its own server + transport pair.
|
* Creates a new MCP session with its own server + transport pair.
|
||||||
* Returns the transport for use by the controller.
|
* Returns the transport for use by the controller.
|
||||||
*/
|
*/
|
||||||
createSession(actor: McpActorContext): {
|
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
|
||||||
sessionId: string;
|
|
||||||
transport: StreamableHTTPServerTransport;
|
|
||||||
} {
|
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
|
|
||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => sessionId,
|
sessionIdGenerator: () => sessionId,
|
||||||
onsessioninitialized: (id) => {
|
onsessioninitialized: (id) => {
|
||||||
this.logger.log(
|
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
|
||||||
`MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +48,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
{ capabilities: { tools: {} } },
|
{ capabilities: { tools: {} } },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.registerTools(server, actor);
|
this.registerTools(server, userId);
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
this.logger.log(`MCP session closed: ${sessionId}`);
|
this.logger.log(`MCP session closed: ${sessionId}`);
|
||||||
@@ -271,126 +61,31 @@ export class McpService implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor });
|
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
|
||||||
return { sessionId, transport };
|
return { sessionId, transport };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the transport for an existing session only when it belongs to the
|
* Returns the transport for an existing session, or null if not found.
|
||||||
* currently authenticated MCP actor. Guessed or cross-tenant session IDs grant
|
|
||||||
* no authority.
|
|
||||||
*/
|
*/
|
||||||
getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null {
|
getSession(sessionId: string): StreamableHTTPServerTransport | null {
|
||||||
const entry = this.sessions.get(sessionId);
|
return this.sessions.get(sessionId)?.transport ?? null;
|
||||||
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.
|
* Registers all platform tools on the given McpServer instance.
|
||||||
*/
|
*/
|
||||||
registerTools(server: McpServer, actor: McpActorContext): void {
|
private registerTools(server: McpServer, _userId: string): void {
|
||||||
// ─── Brain: Project tools ────────────────────────────────────────────
|
// ─── Brain: Project tools ────────────────────────────────────────────
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'brain_list_projects',
|
'brain_list_projects',
|
||||||
{
|
{
|
||||||
description: 'List all projects in the brain.',
|
description: 'List all projects in the brain.',
|
||||||
inputSchema: strictObject({}),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async () => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_list_projects', params);
|
const projects = await this.brain.projects.findAll();
|
||||||
const projects = filterProjectsForActor(
|
|
||||||
actor,
|
|
||||||
(await this.brain.projects.findAll()) as ProjectLike[],
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
||||||
};
|
};
|
||||||
@@ -401,21 +96,17 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_get_project',
|
'brain_get_project',
|
||||||
{
|
{
|
||||||
description: 'Get a project by ID.',
|
description: 'Get a project by ID.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
id: z.string().describe('Project ID (UUID)'),
|
id: z.string().describe('Project ID (UUID)'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async ({ id, ...params }) => {
|
async ({ id }) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_get_project', params);
|
const project = await this.brain.projects.findById(id);
|
||||||
const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined;
|
|
||||||
const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined;
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
text: authorizedProject
|
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
|
||||||
? JSON.stringify(authorizedProject, null, 2)
|
|
||||||
: `Project not found: ${id}`,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -428,23 +119,20 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_list_tasks',
|
'brain_list_tasks',
|
||||||
{
|
{
|
||||||
description: 'List tasks, optionally filtered by project, mission, or status.',
|
description: 'List tasks, optionally filtered by project, mission, or status.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
projectId: z.string().optional().describe('Filter by project ID'),
|
projectId: z.string().optional().describe('Filter by project ID'),
|
||||||
missionId: z.string().optional().describe('Filter by mission ID'),
|
missionId: z.string().optional().describe('Filter by mission ID'),
|
||||||
status: z.string().optional().describe('Filter by status'),
|
status: z.string().optional().describe('Filter by status'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ projectId, missionId, status }) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_list_tasks', params);
|
|
||||||
const { projectId, missionId, status } = params;
|
|
||||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
let tasks;
|
let tasks;
|
||||||
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
||||||
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
||||||
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
||||||
else tasks = await this.brain.tasks.findAll();
|
else tasks = await this.brain.tasks.findAll();
|
||||||
const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]);
|
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] };
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -452,7 +140,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_create_task',
|
'brain_create_task',
|
||||||
{
|
{
|
||||||
description: 'Create a new task in the brain.',
|
description: 'Create a new task in the brain.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
title: z.string().describe('Task title'),
|
title: z.string().describe('Task title'),
|
||||||
description: z.string().optional().describe('Task description'),
|
description: z.string().optional().describe('Task description'),
|
||||||
projectId: z.string().optional().describe('Project ID'),
|
projectId: z.string().optional().describe('Project ID'),
|
||||||
@@ -461,8 +149,6 @@ export class McpService implements OnModuleDestroy {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async (params) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_create_task', params);
|
|
||||||
await this.assertTaskCreateScopeAuthorized(actor, params);
|
|
||||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
const task = await this.brain.tasks.create({
|
const task = await this.brain.tasks.create({
|
||||||
...params,
|
...params,
|
||||||
@@ -476,7 +162,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_update_task',
|
'brain_update_task',
|
||||||
{
|
{
|
||||||
description: 'Update an existing task.',
|
description: 'Update an existing task.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
id: z.string().describe('Task ID'),
|
id: z.string().describe('Task ID'),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -485,17 +171,9 @@ export class McpService implements OnModuleDestroy {
|
|||||||
.optional()
|
.optional()
|
||||||
.describe('not-started, in-progress, blocked, done, cancelled'),
|
.describe('not-started, in-progress, blocked, done, cancelled'),
|
||||||
priority: z.string().optional(),
|
priority: z.string().optional(),
|
||||||
projectId: z.string().optional().describe('Project ID'),
|
|
||||||
missionId: z.string().optional().describe('Mission ID'),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async ({ id, ...updates }) => {
|
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 TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
const task = await this.brain.tasks.update(id, {
|
const task = await this.brain.tasks.update(id, {
|
||||||
@@ -520,19 +198,14 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_list_missions',
|
'brain_list_missions',
|
||||||
{
|
{
|
||||||
description: 'List all missions, optionally filtered by project.',
|
description: 'List all missions, optionally filtered by project.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
projectId: z.string().optional().describe('Filter by project ID'),
|
projectId: z.string().optional().describe('Filter by project ID'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ projectId }) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_list_missions', params);
|
const missions = projectId
|
||||||
const { projectId } = params;
|
|
||||||
const missions = await this.filterMissionsForActor(
|
|
||||||
actor,
|
|
||||||
(projectId
|
|
||||||
? await this.brain.missions.findByProject(projectId)
|
? await this.brain.missions.findByProject(projectId)
|
||||||
: await this.brain.missions.findAll()) as MissionLike[],
|
: await this.brain.missions.findAll();
|
||||||
);
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -540,12 +213,13 @@ export class McpService implements OnModuleDestroy {
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'brain_list_conversations',
|
'brain_list_conversations',
|
||||||
{
|
{
|
||||||
description: 'List conversations for the authenticated MCP actor.',
|
description: 'List conversations for a user.',
|
||||||
inputSchema: strictObject({}),
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ userId }) => {
|
||||||
assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
|
const conversations = await this.brain.conversations.findAll(userId);
|
||||||
const conversations = await this.brain.conversations.findAll(actor.userId);
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
||||||
};
|
};
|
||||||
@@ -558,15 +232,14 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'memory_search',
|
'memory_search',
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
|
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID to search memory for'),
|
||||||
query: z.string().describe('Natural language search query'),
|
query: z.string().describe('Natural language search query'),
|
||||||
limit: z.number().optional().describe('Max results (default 5)'),
|
limit: z.number().optional().describe('Max results (default 5)'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ userId, query, limit }) => {
|
||||||
assertMcpToolAuthorized(actor, 'memory_search', params);
|
|
||||||
const { query, limit } = params;
|
|
||||||
if (!this.embeddings.available) {
|
if (!this.embeddings.available) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -578,11 +251,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const embedding = await this.embeddings.embed(query);
|
const embedding = await this.embeddings.embed(query);
|
||||||
const results = await this.memory.insights.searchByEmbedding(
|
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
||||||
actor.userId,
|
|
||||||
embedding,
|
|
||||||
limit ?? 5,
|
|
||||||
);
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -590,21 +259,20 @@ export class McpService implements OnModuleDestroy {
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'memory_get_preferences',
|
'memory_get_preferences',
|
||||||
{
|
{
|
||||||
description: 'Retrieve stored preferences for the authenticated MCP actor.',
|
description: 'Retrieve stored preferences for a user.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
category: z
|
category: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ userId, category }) => {
|
||||||
assertMcpToolAuthorized(actor, 'memory_get_preferences', params);
|
|
||||||
const { category } = params;
|
|
||||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||||
const prefs = category
|
const prefs = category
|
||||||
? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
|
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
|
||||||
: await this.memory.preferences.findByUser(actor.userId);
|
: await this.memory.preferences.findByUser(userId);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -613,8 +281,9 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'memory_save_preference',
|
'memory_save_preference',
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
|
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
key: z.string().describe('Preference key'),
|
key: z.string().describe('Preference key'),
|
||||||
value: z.string().describe('Preference value (JSON string)'),
|
value: z.string().describe('Preference value (JSON string)'),
|
||||||
category: z
|
category: z
|
||||||
@@ -623,9 +292,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
.describe('Category: communication, coding, workflow, appearance, general'),
|
.describe('Category: communication, coding, workflow, appearance, general'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ userId, key, value, category }) => {
|
||||||
assertMcpToolAuthorized(actor, 'memory_save_preference', params);
|
|
||||||
const { key, value, category } = params;
|
|
||||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||||
let parsedValue: unknown;
|
let parsedValue: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -634,7 +301,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
parsedValue = value;
|
parsedValue = value;
|
||||||
}
|
}
|
||||||
const pref = await this.memory.preferences.upsert({
|
const pref = await this.memory.preferences.upsert({
|
||||||
userId: actor.userId,
|
userId,
|
||||||
key,
|
key,
|
||||||
value: parsedValue,
|
value: parsedValue,
|
||||||
category: (category as Cat) ?? 'general',
|
category: (category as Cat) ?? 'general',
|
||||||
@@ -648,8 +315,9 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'memory_save_insight',
|
'memory_save_insight',
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
|
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
|
userId: z.string().describe('User ID'),
|
||||||
content: z.string().describe('The insight or knowledge to store'),
|
content: z.string().describe('The insight or knowledge to store'),
|
||||||
category: z
|
category: z
|
||||||
.string()
|
.string()
|
||||||
@@ -657,13 +325,11 @@ export class McpService implements OnModuleDestroy {
|
|||||||
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ userId, content, category }) => {
|
||||||
assertMcpToolAuthorized(actor, 'memory_save_insight', params);
|
|
||||||
const { content, category } = params;
|
|
||||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||||
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
||||||
const insight = await this.memory.insights.create({
|
const insight = await this.memory.insights.create({
|
||||||
userId: actor.userId,
|
userId,
|
||||||
content,
|
content,
|
||||||
embedding,
|
embedding,
|
||||||
source: 'agent',
|
source: 'agent',
|
||||||
@@ -680,11 +346,16 @@ export class McpService implements OnModuleDestroy {
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
||||||
inputSchema: strictObject({}),
|
inputSchema: z.object({
|
||||||
|
projectPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Project path. Defaults to gateway working directory.'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ projectPath }) => {
|
||||||
assertMcpToolAuthorized(actor, 'coord_mission_status', params);
|
const resolvedPath = projectPath ?? process.cwd();
|
||||||
const status = await this.coordService.getMissionStatus(process.cwd());
|
const status = await this.coordService.getMissionStatus(resolvedPath);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -700,11 +371,16 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'coord_list_tasks',
|
'coord_list_tasks',
|
||||||
{
|
{
|
||||||
description: 'List all tasks from the orchestration TASKS.md file.',
|
description: 'List all tasks from the orchestration TASKS.md file.',
|
||||||
inputSchema: strictObject({}),
|
inputSchema: z.object({
|
||||||
|
projectPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Project path. Defaults to gateway working directory.'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ projectPath }) => {
|
||||||
assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
|
const resolvedPath = projectPath ?? process.cwd();
|
||||||
const tasks = await this.coordService.listTasks(process.cwd());
|
const tasks = await this.coordService.listTasks(resolvedPath);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -713,14 +389,17 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'coord_task_detail',
|
'coord_task_detail',
|
||||||
{
|
{
|
||||||
description: 'Get detailed status for a specific orchestration task.',
|
description: 'Get detailed status for a specific orchestration task.',
|
||||||
inputSchema: strictObject({
|
inputSchema: z.object({
|
||||||
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
||||||
|
projectPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Project path. Defaults to gateway working directory.'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async ({ taskId, projectPath }) => {
|
||||||
assertMcpToolAuthorized(actor, 'coord_task_detail', params);
|
const resolvedPath = projectPath ?? process.cwd();
|
||||||
const { taskId } = params;
|
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
||||||
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import {
|
|||||||
createMemory,
|
createMemory,
|
||||||
type Memory,
|
type Memory,
|
||||||
createMemoryAdapter,
|
createMemoryAdapter,
|
||||||
createOperatorMemoryPlugin,
|
|
||||||
type MemoryAdapter,
|
type MemoryAdapter,
|
||||||
type MemoryConfig,
|
type MemoryConfig,
|
||||||
type OperatorMemoryPlugin,
|
|
||||||
} from '@mosaicstack/memory';
|
} from '@mosaicstack/memory';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaicstack/db';
|
||||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
import type { StorageAdapter } from '@mosaicstack/storage';
|
||||||
@@ -16,9 +14,6 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js';
|
|||||||
import { MEMORY } from './memory.tokens.js';
|
import { MEMORY } from './memory.tokens.js';
|
||||||
import { MemoryController } from './memory.controller.js';
|
import { MemoryController } from './memory.controller.js';
|
||||||
import { EmbeddingService } from './embedding.service.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';
|
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
|
||||||
|
|
||||||
@@ -43,24 +38,9 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter)
|
|||||||
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
|
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
|
||||||
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
|
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,
|
EmbeddingService,
|
||||||
],
|
],
|
||||||
controllers: [MemoryController],
|
controllers: [MemoryController],
|
||||||
exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService],
|
exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService],
|
||||||
})
|
})
|
||||||
export class MemoryModule {}
|
export class MemoryModule {}
|
||||||
|
|||||||
@@ -1,715 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
type OnModuleInit,
|
type OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
|
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
|
||||||
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
||||||
import { PluginService } from './plugin.service.js';
|
import { PluginService } from './plugin.service.js';
|
||||||
import type { IChannelPlugin } from './plugin.interface.js';
|
import type { IChannelPlugin } from './plugin.interface.js';
|
||||||
@@ -50,58 +50,19 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
|
|||||||
|
|
||||||
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
|
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[] {
|
function createPluginRegistry(): IChannelPlugin[] {
|
||||||
const plugins: IChannelPlugin[] = [];
|
const plugins: IChannelPlugin[] = [];
|
||||||
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
||||||
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
||||||
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
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 (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(
|
plugins.push(
|
||||||
new DiscordChannelPluginAdapter(
|
new DiscordChannelPluginAdapter(
|
||||||
new DiscordPlugin({
|
new DiscordPlugin({
|
||||||
token: discordToken,
|
token: discordToken,
|
||||||
guildId: discordGuildId,
|
guildId: discordGuildId,
|
||||||
gatewayUrl: discordGatewayUrl,
|
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'],
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
|
|
||||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
||||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
|
||||||
const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) =>
|
`mosaic:session:${sessionId}:system:fragments`;
|
||||||
`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
|
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
|
||||||
|
|
||||||
interface OverrideFragment {
|
interface OverrideFragment {
|
||||||
@@ -24,9 +20,9 @@ export class SystemOverrideService {
|
|||||||
this.handle = createQueue();
|
this.handle = createQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
async set(sessionId: string, override: string): Promise<void> {
|
||||||
// Load existing fragments
|
// Load existing fragments
|
||||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
|
||||||
const fragments: OverrideFragment[] = existing
|
const fragments: OverrideFragment[] = existing
|
||||||
? (JSON.parse(existing) as OverrideFragment[])
|
? (JSON.parse(existing) as OverrideFragment[])
|
||||||
: [];
|
: [];
|
||||||
@@ -41,11 +37,11 @@ export class SystemOverrideService {
|
|||||||
// Store both: fragments array and condensed result
|
// Store both: fragments array and condensed result
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.setex(
|
pipeline.setex(
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||||
SYSTEM_OVERRIDE_TTL_SECONDS,
|
SYSTEM_OVERRIDE_TTL_SECONDS,
|
||||||
JSON.stringify(fragments),
|
JSON.stringify(fragments),
|
||||||
);
|
);
|
||||||
pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||||
await pipeline.exec();
|
await pipeline.exec();
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@@ -53,21 +49,21 @@ export class SystemOverrideService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
async get(sessionId: string): Promise<string | null> {
|
||||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async renew(sessionId: string): Promise<void> {
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
await pipeline.exec();
|
await pipeline.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async clear(sessionId: string): Promise<void> {
|
||||||
await this.handle.redis.del(
|
await this.handle.redis.del(
|
||||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
SESSION_SYSTEM_KEY(sessionId),
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||||
);
|
);
|
||||||
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,23 +162,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove every existing repeatable schedule for a job name. This supports
|
|
||||||
* safe retirement of previously registered system-wide jobs.
|
|
||||||
*/
|
|
||||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
|
||||||
const queue = this.getQueue(queueName);
|
|
||||||
const jobs = await queue.getRepeatableJobs();
|
|
||||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
|
||||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
|
||||||
if (matchingJobs.length > 0) {
|
|
||||||
this.logger.log(
|
|
||||||
`Removed ${matchingJobs.length} repeatable "${jobName}" job(s) from "${queueName}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return matchingJobs.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a Worker for the given queue name with error handling and
|
* Register a Worker for the given queue name with error handling and
|
||||||
* exponential backoff.
|
* exponential backoff.
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
||||||
**Phase:** Execution (workstream W1 in planning-complete state)
|
**Phase:** Execution (workstream W1 in planning-complete state)
|
||||||
**Current Workstream:** W1 — Federation v1
|
**Current Workstream:** W1 — Federation v1
|
||||||
**Progress:** 0 / 3 declared workstreams complete (more workstreams will be declared as scope is refined)
|
**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined)
|
||||||
**Status:** active (continuous since 2026-03-13)
|
**Status:** active (continuous since 2026-03-13)
|
||||||
**Last Updated:** 2026-07-14 (W3 Native Kanban/SOT canon independently approved under issue #751)
|
**Last Updated:** 2026-04-19 (manifest authored at the rollup level; install-ux-v2 archived; W1 federation planning landed via PR #468)
|
||||||
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
||||||
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
||||||
|
|
||||||
@@ -68,11 +68,9 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
|||||||
## Workstreams
|
## Workstreams
|
||||||
|
|
||||||
| # | ID | Name | Status | Manifest | Notes |
|
| # | ID | Name | Status | Manifest | Notes |
|
||||||
| --- | ---- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||||
| W3 | KBN | Native Kanban and canonical task SOT | planning-complete | [docs/native-kanban-sot/MISSION-MANIFEST.md](./native-kanban-sot/MISSION-MANIFEST.md) | P0–P3; issue #751; implementation held until canon merge |
|
|
||||||
| W4+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
|
||||||
|
|
||||||
### Likely Additional Workstreams (Not Yet Declared)
|
### Likely Additional Workstreams (Not Yet Declared)
|
||||||
|
|
||||||
|
|||||||
258
docs/PRD.md
258
docs/PRD.md
@@ -79,242 +79,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
|
||||||
|
|
||||||
### Problem and objective
|
|
||||||
|
|
||||||
The local Mosaic fleet has a roster, generated agent environment files, user-systemd units, tmux
|
|
||||||
sessions, heartbeat files, examples, profiles, and separate gateway-backed agent records. These
|
|
||||||
planes have drifted and are not one safe operator lifecycle. The objective is one **local fleet
|
|
||||||
roster** as the desired-state SSOT, with generated environment, systemd, tmux, and heartbeat
|
|
||||||
artifacts as rebuildable projections; it does not merge the local fleet control plane with the
|
|
||||||
gateway-backed agent catalog.
|
|
||||||
|
|
||||||
### Normative requirements
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
|
|
||||||
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
|
|
||||||
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
|
|
||||||
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
|
|
||||||
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
|
|
||||||
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
|
|
||||||
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
|
|
||||||
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
|
|
||||||
| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
|
|
||||||
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
1. `AC-FCM-01`: A valid local v2 roster can be parsed from YAML or JSON, validated structurally and semantically through the shared resolver, and rendered canonically; invalid fields, duplicate names, unresolved classes, unsupported runtime/model combinations, socket ambiguity, and incompatible options fail closed.
|
|
||||||
2. `AC-FCM-02`: `plan` reports deterministic desired-versus-observed differences for roster, generated environment, systemd enablement, tmux/session, heartbeat, installed-asset revision, and provable orphans without mutation; `apply --check` reports drift without mutation.
|
|
||||||
3. `AC-FCM-03`: Local create/update/delete is generation-guarded, atomic, idempotent, and safe by default; it permits supported runtime/model/harness/effort/workdir/role changes without direct editing of generated environment files and does not start a newly created agent unless explicitly persisted.
|
|
||||||
4. `AC-FCM-04`: The generated-env/local-override launch chain rejects generated-key shadowing, arbitrary command override, unknown keys, shell evaluation, and sensitive-value diagnostics before any agent starts; known-safe legacy input is regenerated or strictly relocated, and forbidden input is quarantined.
|
|
||||||
5. `AC-FCM-05`: Local lifecycle reconciliation implements the persisted/transient start-stop rules, exact default/named tmux socket targeting, systemd/tmux status, stale generated state, unmanaged-session reporting, and rollback without surprise restarts or fuzzy destructive targeting.
|
|
||||||
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
|
|
||||||
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
|
|
||||||
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
|
|
||||||
|
|
||||||
### M0 implementation gate
|
|
||||||
|
|
||||||
No source, schema, role, example, profile, systemd, or live-fleet change is authorized before M0
|
|
||||||
lands. M0 consists only of these normative requirements, the complete task DAG, the scoped
|
|
||||||
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
|
||||||
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tess Interaction Agent Workstream (TESS)
|
|
||||||
|
|
||||||
### Problem and Objective
|
|
||||||
|
|
||||||
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
|
|
||||||
|
|
||||||
The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
#### In Scope
|
|
||||||
|
|
||||||
1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors.
|
|
||||||
2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery.
|
|
||||||
3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs.
|
|
||||||
4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery.
|
|
||||||
5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery.
|
|
||||||
6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority.
|
|
||||||
7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations.
|
|
||||||
8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence.
|
|
||||||
9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction.
|
|
||||||
10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery.
|
|
||||||
11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details.
|
|
||||||
12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution.
|
|
||||||
13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval.
|
|
||||||
14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority.
|
|
||||||
15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields.
|
|
||||||
16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay.
|
|
||||||
17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows.
|
|
||||||
18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest.
|
|
||||||
19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay.
|
|
||||||
20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job.
|
|
||||||
21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics.
|
|
||||||
22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates.
|
|
||||||
|
|
||||||
#### Out of Scope
|
|
||||||
|
|
||||||
1. Replacing Mos as coding/general fleet orchestrator.
|
|
||||||
2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas.
|
|
||||||
3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated.
|
|
||||||
4. Unrestricted shell execution from Discord.
|
|
||||||
5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable.
|
|
||||||
6. Replacing tmux before Matrix/native transport reaches operational parity.
|
|
||||||
|
|
||||||
### Stakeholder and User Requirements
|
|
||||||
|
|
||||||
- Jason must be able to converse with the same Tess session from Discord and CLI.
|
|
||||||
- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session.
|
|
||||||
- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls.
|
|
||||||
- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority.
|
|
||||||
- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale.
|
|
||||||
|
|
||||||
### Non-Functional Requirements
|
|
||||||
|
|
||||||
1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals.
|
|
||||||
2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart.
|
|
||||||
3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions.
|
|
||||||
4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events.
|
|
||||||
5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests.
|
|
||||||
6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy.
|
|
||||||
7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally.
|
|
||||||
2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors.
|
|
||||||
3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials.
|
|
||||||
4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work.
|
|
||||||
5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed.
|
|
||||||
6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects.
|
|
||||||
7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited.
|
|
||||||
8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass.
|
|
||||||
9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green.
|
|
||||||
10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence.
|
|
||||||
11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap.
|
|
||||||
|
|
||||||
### Constraints, Dependencies, Risks, and Assumptions
|
|
||||||
|
|
||||||
- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required.
|
|
||||||
- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control.
|
|
||||||
- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests.
|
|
||||||
- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation.
|
|
||||||
- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes.
|
|
||||||
- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication.
|
|
||||||
- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification.
|
|
||||||
- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys.
|
|
||||||
- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation.
|
|
||||||
- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification.
|
|
||||||
- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities.
|
|
||||||
|
|
||||||
### Testing and Delivery Intent
|
|
||||||
|
|
||||||
Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mos Runtime Portability Workstream (MOS-PORT)
|
|
||||||
|
|
||||||
### Problem and Objective
|
|
||||||
|
|
||||||
Mos is currently identified partly by a harness-native session and communication process. Replacement/rebinding exists, but no gateway-enforced logical identity or fencing prevents a stale harness from continuing to reply or execute effects after takeover.
|
|
||||||
|
|
||||||
The objective is to make Mos a server-derived logical Mosaic identity whose authority can move safely among runtime connectors. The gateway owns identity, lease, policy, and audit; harnesses remain replaceable adapters.
|
|
||||||
|
|
||||||
### M1 Requirements
|
|
||||||
|
|
||||||
1. `MOS-PORT-ID-001`: Define a normalized logical-agent identity independent of Claude Code, Pi, Codex, tmux, Matrix, and provider-native session IDs.
|
|
||||||
2. `MOS-PORT-LEASE-001`: Persist one exclusive connector lease per tenant/logical-agent/binding with CAS acquisition, monotonic fencing epoch, TTL, heartbeat, explicit release, and takeover.
|
|
||||||
3. `MOS-PORT-FENCE-001`: Bind every connector dispatch/execution grant to the current server-derived tenant, logical identity, binding, connector, scopes, expiry, and lease epoch.
|
|
||||||
4. `MOS-PORT-FENCE-002`: Reject and audit stale, expired, forged, cross-tenant, cross-binding, and unauthorized grants before connector, channel, provider, or tool side effects.
|
|
||||||
5. `MOS-PORT-OBS-001`: Emit credential-safe correlation/audit events for lease acquire, renew, takeover, reject, release, and expiry.
|
|
||||||
6. `MOS-PORT-ARCH-001`: Runtime/provider adapters consume normalized lease context without adding harness-native schemas to Mosaic core.
|
|
||||||
|
|
||||||
### M1 Acceptance Criteria
|
|
||||||
|
|
||||||
1. `AC-MOS-PORT-01`: Two contenders for one binding cannot simultaneously hold current authority under concurrency.
|
|
||||||
2. `AC-MOS-PORT-02`: Successful takeover increments the fencing epoch and every operation from the old epoch fails closed before side effects.
|
|
||||||
3. `AC-MOS-PORT-03`: Gateway/database restart preserves lease and epoch state; expired leases can be recovered only through the authorized takeover path.
|
|
||||||
4. `AC-MOS-PORT-04`: Cross-tenant, cross-agent, cross-binding, forged, and expired lease/grant cases are denied and audited.
|
|
||||||
5. `AC-MOS-PORT-05`: Unit, migration, repository close/reopen, concurrency, abuse, gateway integration, independent security review, CI, and documentation gates pass.
|
|
||||||
|
|
||||||
### Deferred to Later #754 Milestones
|
|
||||||
|
|
||||||
Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete Claude/Pi/Codex adapters, channel cutover, and full cross-harness failover/rollback E2E are explicitly out of M1 scope.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### High-Level System Diagram
|
### High-Level System Diagram
|
||||||
@@ -669,8 +433,7 @@ Discord remote control channel. Architecture inspired by OpenClaw (https://githu
|
|||||||
- Single-guild binding only (v0.1.0) — prevents data leaks between servers
|
- Single-guild binding only (v0.1.0) — prevents data leaks between servers
|
||||||
- Receives Discord messages, dispatches through gateway routing
|
- Receives Discord messages, dispatches through gateway routing
|
||||||
- Streams agent responses back to Discord (chunked for 2000-char limit)
|
- Streams agent responses back to Discord (chunked for 2000-char limit)
|
||||||
- Routes authorized untagged messages in-channel; mentions create threads (or reuse the same message's attached thread) for multi-turn topics
|
- Supports mention-based activation, thread management for multi-turn
|
||||||
- Uses stable logical-agent/channel conversation addresses independent of the active harness/provider
|
|
||||||
- Bot pairing and permission management (Discord user → Mosaic user mapping)
|
- Bot pairing and permission management (Discord user → Mosaic user mapping)
|
||||||
- DM support for private conversations
|
- DM support for private conversations
|
||||||
|
|
||||||
@@ -843,12 +606,10 @@ Telegram remote control channel.
|
|||||||
|
|
||||||
### FR-9: Remote Control — Discord
|
### FR-9: Remote Control — Discord
|
||||||
|
|
||||||
- Discord bot that connects to the gateway through a transport-neutral channel adapter contract
|
- Discord bot that connects to the gateway
|
||||||
- Authorized messages in configured agent-bound channels work without a mention and respond in-channel
|
- Mention-based activation in channels
|
||||||
- Mentions in parent channels create threads, or reuse a thread already attached to that same native message, for multi-turn conversations
|
|
||||||
- Messages already in a thread remain there without requiring repeated mentions
|
|
||||||
- Stable logical-agent/channel conversation identity survives underlying harness/provider changes
|
|
||||||
- DM support for private conversations
|
- DM support for private conversations
|
||||||
|
- Thread creation for multi-turn conversations
|
||||||
- Chunked message delivery (Discord 2000-char limit)
|
- Chunked message delivery (Discord 2000-char limit)
|
||||||
- Bot configuration via web dashboard
|
- Bot configuration via web dashboard
|
||||||
- Permission management (which Discord users/roles can interact)
|
- Permission management (which Discord users/roles can interact)
|
||||||
@@ -1032,13 +793,10 @@ Telegram remote control channel.
|
|||||||
|
|
||||||
### AC-3: Discord Remote Control
|
### AC-3: Discord Remote Control
|
||||||
|
|
||||||
- [ ] Discord bot connects through the harness-neutral channel contract
|
- [ ] Discord bot connects and responds to mentions
|
||||||
- [ ] Authorized untagged channel messages route through the gateway and respond in-channel
|
- [ ] Messages route through gateway to agent pool
|
||||||
- [ ] Mentioned parent-channel messages create a thread (or reuse their already-attached thread) and respond there
|
|
||||||
- [ ] Existing-thread follow-ups stay in the thread without repeated mentions
|
|
||||||
- [ ] Channel/session identity remains stable while the underlying harness/provider changes
|
|
||||||
- [ ] Responses stream back to Discord (chunked)
|
- [ ] Responses stream back to Discord (chunked)
|
||||||
- [ ] Unauthorized guilds, channels, users, pairings, and roles create no thread and dispatch no message
|
- [ ] Thread creation for multi-turn conversations
|
||||||
|
|
||||||
### AC-4: Gateway Orchestration
|
### AC-4: Gateway Orchestration
|
||||||
|
|
||||||
@@ -1237,7 +995,7 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
|||||||
|
|
||||||
6. ASSUMPTION: **Log summarization uses Haiku-tier LLM by default, configurable.** Haiku is well-suited for summarization (compression, not generation — source material is in context). Guardrails: structured output via Zod schema (force extraction of decisions/tools/outcomes/errors as discrete fields), chunked per-session processing (no bulk conflation), extraction-focused prompts. Raw logs stay in hot tier (7 days) as safety net. Users can override the summarization model via routing engine config if they want higher fidelity. Rationale: Haiku is 10-20x cheaper than Sonnet; log summarization runs on schedule against large volumes where cost matters.
|
6. ASSUMPTION: **Log summarization uses Haiku-tier LLM by default, configurable.** Haiku is well-suited for summarization (compression, not generation — source material is in context). Guardrails: structured output via Zod schema (force extraction of decisions/tools/outcomes/errors as discrete fields), chunked per-session processing (no bulk conflation), extraction-focused prompts. Raw logs stay in hot tier (7 days) as safety net. Users can override the summarization model via routing engine config if they want higher fidelity. Rationale: Haiku is 10-20x cheaper than Sonnet; log summarization runs on schedule against large volumes where cost matters.
|
||||||
|
|
||||||
7. ASSUMPTION: **Discord plugin starts minimal and single-guild only** — explicitly configured agent-bound channels accept authorized untagged messages in-channel, while mentions create threads or reuse a thread already attached to that same native message; responses are chunked. Single guild binding prevents data leaks between servers. DM support, voice, components, slash commands, and multi-guild operation are post-beta. Rationale: Ship the requested core interaction model while preserving default-deny data isolation.
|
7. ASSUMPTION: **Discord plugin starts minimal and single-guild only** — DM support, mention-based channel activation, thread management, chunked responses. Single guild binding to prevent data leaks between servers. Advanced features (voice, components, slash commands, multi-guild) are post-beta. Rationale: Proven pattern from OpenClaw; ship core interaction first; data isolation is non-negotiable.
|
||||||
|
|
||||||
8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel.
|
8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel.
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# Documentation Sitemap
|
|
||||||
|
|
||||||
## Official channel plugins
|
|
||||||
|
|
||||||
- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts.
|
|
||||||
- [Discord administrator configuration](guides/admin-guide.md#discord-ingress-security) — secrets, allowlists, bindings, role policy, and thread permissions.
|
|
||||||
- [Discord user workflow](tess/USER-GUIDE.md#discord-conversations) — in-channel messages, mention-created threads, and runtime-transparent continuity.
|
|
||||||
- [Channel plugin authoring](tess/PLUGIN-GUIDE.md#official-channel-adapter-contract) — requirements for future Matrix, Slack, and other official adapters.
|
|
||||||
- [Discord package guide](../plugins/discord/README.md) — package behavior, configuration shape, and development commands.
|
|
||||||
|
|
||||||
## Native Kanban and canonical task SOT
|
|
||||||
|
|
||||||
- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0–P3 requirements and acceptance criteria.
|
|
||||||
- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order.
|
|
||||||
- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model.
|
|
||||||
- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries.
|
|
||||||
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
|
|
||||||
- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001–016 findings that blocked the first draft.
|
|
||||||
- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict.
|
|
||||||
- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review.
|
|
||||||
|
|
||||||
## Tess interaction agent
|
|
||||||
|
|
||||||
### Operator guides
|
|
||||||
|
|
||||||
- [User guide](tess/USER-GUIDE.md) — authorized session, attach, send, stop, and handoff workflows.
|
|
||||||
- [Admin guide](tess/ADMIN-GUIDE.md) — deployment configuration, policy, and approval controls.
|
|
||||||
- [Developer guide](tess/DEVELOPER-GUIDE.md) — provider contracts, scope boundaries, and test workflow.
|
|
||||||
- [Plugin guide](tess/PLUGIN-GUIDE.md) — adapter, redaction, and identity-as-data requirements.
|
|
||||||
- [Operations guide](tess/OPERATIONS-GUIDE.md) — readiness, recovery, and incident-safe procedures.
|
|
||||||
|
|
||||||
### Architecture and security
|
|
||||||
|
|
||||||
- [Architecture](tess/ARCHITECTURE.md)
|
|
||||||
- [Threat model](tess/THREAT-MODEL.md)
|
|
||||||
- [Mos coordination boundary](tess/MOS-COORDINATION.md)
|
|
||||||
- [Hermes runtime adapter design](tess/hermes-runtime-adapter-design.md)
|
|
||||||
- [Operator plugin sketch](tess/M4-003-OPERATOR-PLUGIN-SKETCH.md)
|
|
||||||
|
|
||||||
### API contract
|
|
||||||
|
|
||||||
- [Tess OpenAPI contract](openapi-tess.yaml)
|
|
||||||
|
|
||||||
### Migration and qualification
|
|
||||||
|
|
||||||
- [Migration inventory](tess/M5-MIGRATION-INVENTORY.md)
|
|
||||||
- [Cutover procedure](tess/M5-MIGRATION-CUTOVER.md)
|
|
||||||
- [Rollback procedure](tess/M5-MIGRATION-ROLLBACK.md)
|
|
||||||
- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md)
|
|
||||||
- [Verification matrix](tess/VERIFICATION-MATRIX.md)
|
|
||||||
- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md)
|
|
||||||
- [Independent Option 2 runtime-portability qualification (2026-07-14)](tess/qualification/2026-07-14-option2-runtime-portability.md)
|
|
||||||
|
|
||||||
## Runtime-neutral Mos portability
|
|
||||||
|
|
||||||
- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies.
|
|
||||||
- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754)
|
|
||||||
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)
|
|
||||||
- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md)
|
|
||||||
- [M1 connector lease operations](guides/mos-connector-lease-operations.md)
|
|
||||||
@@ -15,11 +15,8 @@
|
|||||||
## Workstream Rollup
|
## Workstream Rollup
|
||||||
|
|
||||||
| id | status | workstream | progress | tasks file | notes |
|
| id | status | workstream | progress | tasks file | notes |
|
||||||
| --- | ----------------- | ------------------------------ | ---------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
|
||||||
| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges |
|
|
||||||
| W4 | planning-complete | Fleet configuration management | 0 / 12 cards | This file (§ Fleet configuration management #758) | Issue #758; M0 docs gate defines the implementation DAG before any fleet mutation |
|
|
||||||
|
|
||||||
## Cross-Cutting Tracking
|
## Cross-Cutting Tracking
|
||||||
|
|
||||||
@@ -43,30 +40,6 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
|
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
|
||||||
3. Follow per-task agent + tier guidance from the workstream manifest
|
3. Follow per-task agent + tier guidance from the workstream manifest
|
||||||
|
|
||||||
## Fleet configuration management (#758) — M0–M5 implementation DAG
|
|
||||||
|
|
||||||
> **PRD:** [Fleet declarative configuration management](./PRD.md#fleet-declarative-configuration-management-workstream-fcm-758) · **M0 acceptance:** [docs IA checklist](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) · **baseline dispositions:** [legacy example/profile inventory](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md)
|
|
||||||
>
|
|
||||||
> Every row below is one independently reviewable card and **one PR**. `depends_on` is a
|
|
||||||
> hard DAG edge; no card may silently absorb another card's scope. All source cards require
|
|
||||||
> the repository quality gates, independent code and security review, terminal-green CI, and
|
|
||||||
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
|
||||||
|
|
||||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
|
||||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
|
|
||||||
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
|
|
||||||
| FCM-M1-002 | in-progress | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Started 2026-07-14 from `aa5b43b`; one shared resolver only; validator certificate-only; merge-gate sole merge authority |
|
|
||||||
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
|
|
||||||
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
|
|
||||||
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
|
|
||||||
| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only |
|
|
||||||
| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting |
|
|
||||||
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
|
|
||||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
|
|
||||||
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
|
|
||||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
|
|
||||||
|
|
||||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||||
|
|
||||||
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
# ADR: Optional AI egress gateways for runtime-neutral Mos
|
|
||||||
|
|
||||||
**Status:** Proposed for controlled prototypes; not approved as Mosaic core
|
|
||||||
|
|
||||||
**Date:** 2026-07-14
|
|
||||||
|
|
||||||
**Issues:** #754, #755
|
|
||||||
|
|
||||||
**Decision owner:** Mosaic Gateway / provider-adapter architecture
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The emergency Mos continuity path kept Claude Code as the harness and translated Anthropic Messages traffic to Codex OAuth through a small localhost proxy. That preserved the existing Claude Discord plugin and transcript, but exposed two architectural facts:
|
|
||||||
|
|
||||||
1. Harness identity, channel entitlement, provider credentials, and inference transport are separate concerns.
|
|
||||||
2. A generic AI gateway can improve provider routing, budgets, and observability, but must not become Mosaic's identity, authorization, tenant, or orchestration boundary.
|
|
||||||
|
|
||||||
The Tess qualification report also found that current provider rebinding is not identity-continuous failover. Mosaic still needs a logical agent identity, durable connector lease/fencing, canonical handoff/checkpoint, exactly-once receipts, concrete harness adapters, and cross-harness rollback E2E.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Mosaic MAY support LiteLLM, Bifrost, the purpose-built Claude/Codex proxy, or future gateways as optional egress implementations behind `IProviderAdapter` / `AgentRuntimeProvider`.
|
|
||||||
|
|
||||||
Mosaic Gateway remains authoritative for:
|
|
||||||
|
|
||||||
- authenticated actor and tenant identity;
|
|
||||||
- logical agent identity and connector binding;
|
|
||||||
- authorization, approval, and policy;
|
|
||||||
- lease epoch and stale-holder fencing;
|
|
||||||
- audit correlation and redaction;
|
|
||||||
- canonical handoff/checkpoint state;
|
|
||||||
- idempotency and side-effect receipts.
|
|
||||||
|
|
||||||
An egress gateway MUST NOT:
|
|
||||||
|
|
||||||
- receive channel ingress directly;
|
|
||||||
- authorize tools or connector ownership;
|
|
||||||
- define Mosaic tenant or agent identity;
|
|
||||||
- persist raw Mosaic handoffs or channel credentials;
|
|
||||||
- bypass adapter capability negotiation;
|
|
||||||
- silently fail over when policy, lease, or provider health is uncertain.
|
|
||||||
|
|
||||||
Allowed topology:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Discord / Matrix / CLI / web
|
|
||||||
↓
|
|
||||||
Mosaic Gateway: identity, authz, lease/fence, approvals, audit
|
|
||||||
↓
|
|
||||||
IProviderAdapter / AgentRuntimeProvider
|
|
||||||
↓
|
|
||||||
optional egress gateway
|
|
||||||
↓
|
|
||||||
upstream provider or subscription-backed OAuth session
|
|
||||||
```
|
|
||||||
|
|
||||||
## Candidate assessment
|
|
||||||
|
|
||||||
### Purpose-built `raine/claude-code-proxy`
|
|
||||||
|
|
||||||
**Disposition:** Approved only for the verified emergency localhost bridge.
|
|
||||||
|
|
||||||
Strengths:
|
|
||||||
|
|
||||||
- explicit Codex device OAuth flow;
|
|
||||||
- small operational surface;
|
|
||||||
- Anthropic Messages translation suitable for Claude Code;
|
|
||||||
- model and reasoning-effort enforcement;
|
|
||||||
- straightforward loopback systemd supervision and rollback.
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
|
|
||||||
- not a Mosaic multi-tenant control plane;
|
|
||||||
- Claude built-in channels still depend on Claude subscription entitlement and feature lookup;
|
|
||||||
- model aliases can obscure the upstream model unless proxy policy/logs are treated as evidence;
|
|
||||||
- no replacement for connector leasing, canonical handoff, or exactly-once effects.
|
|
||||||
|
|
||||||
### LiteLLM
|
|
||||||
|
|
||||||
**Disposition:** Candidate for a formal adapter-only prototype and terms/security review.
|
|
||||||
|
|
||||||
Current documentation states that ChatGPT subscription access is available through an OAuth device-code flow. LiteLLM also provides broad provider routing, virtual keys, budgets, observability, and OpenAI/Anthropic-compatible surfaces.
|
|
||||||
|
|
||||||
Required prototype gates:
|
|
||||||
|
|
||||||
- verify the exact ChatGPT subscription OAuth flow and supported models against current provider terms;
|
|
||||||
- document token location, encryption, revocation, refresh, scope, and incident response;
|
|
||||||
- prove tenant isolation and prevent virtual keys from becoming Mosaic principals;
|
|
||||||
- verify streaming, tool calls, reasoning controls, cancellation, and idempotency metadata;
|
|
||||||
- fail closed instead of selecting an unhealthy provider merely to return a result;
|
|
||||||
- demonstrate that Mosaic audit correlation survives gateway retries/failover;
|
|
||||||
- keep channel ingress and connector credentials outside LiteLLM.
|
|
||||||
|
|
||||||
Source references:
|
|
||||||
|
|
||||||
- [LiteLLM ChatGPT subscription provider](https://docs.litellm.ai/docs/providers/chatgpt)
|
|
||||||
- [LiteLLM providers](https://docs.litellm.ai/docs/providers)
|
|
||||||
|
|
||||||
### Bifrost
|
|
||||||
|
|
||||||
**Disposition:** Candidate for governance/routing research; subscription OAuth compatibility unverified.
|
|
||||||
|
|
||||||
Useful concepts include virtual keys, budgets, rate limits, weighted load balancing, and automatic provider failover. Those features may inform Mosaic egress policy, but Bifrost virtual keys are downstream credentials—not Mosaic actors or tenants.
|
|
||||||
|
|
||||||
Required prototype gates:
|
|
||||||
|
|
||||||
- verify Codex/ChatGPT subscription OAuth rather than assuming API-key compatibility;
|
|
||||||
- map budgets and virtual keys to server-derived Mosaic tenants without duplicating authority;
|
|
||||||
- prove failover does not violate connector lease, approval, or exactly-once semantics;
|
|
||||||
- ensure request/response logs are redacted before persistence;
|
|
||||||
- disable or constrain automatic failover when policy or side-effect state is ambiguous.
|
|
||||||
|
|
||||||
Source references:
|
|
||||||
|
|
||||||
- [Bifrost overview](https://docs.getbifrost.ai/overview)
|
|
||||||
- [Bifrost repository](https://github.com/maximhq/bifrost)
|
|
||||||
|
|
||||||
### `teremterem/claude-code-gpt-5-codex`
|
|
||||||
|
|
||||||
**Disposition:** Not selected as the emergency implementation; useful as a historical LiteLLM recipe.
|
|
||||||
|
|
||||||
The reviewed repository uses `OPENAI_API_KEY`, tells previously authenticated Claude users to log out, and documents a Claude Web Search schema incompatibility. Logging Claude out conflicts with the channel-entitlement requirement observed in the live Mos cutover. The repository therefore does not, as provided, satisfy subscription-OAuth plus built-in-channel continuity.
|
|
||||||
|
|
||||||
Source references:
|
|
||||||
|
|
||||||
- [Repository](https://github.com/teremterem/claude-code-gpt-5-codex)
|
|
||||||
- [Environment template](https://github.com/teremterem/claude-code-gpt-5-codex/blob/main/.env.template)
|
|
||||||
|
|
||||||
## Security consequences
|
|
||||||
|
|
||||||
- Subscription OAuth grants are high-value credentials and require the same lifecycle controls as service credentials.
|
|
||||||
- Downstream virtual keys reduce provider-key exposure but do not establish user, tenant, or agent authority.
|
|
||||||
- Automatic retry/failover can duplicate tool or external side effects unless Mosaic owns operation IDs and receipts.
|
|
||||||
- Gateway telemetry can contain prompts, tool schemas, and model output; redaction and retention policy must apply before persistence.
|
|
||||||
- A localhost unauthenticated translation endpoint must remain loopback-only and process-isolated.
|
|
||||||
|
|
||||||
## Acceptance before production use
|
|
||||||
|
|
||||||
1. Threat model and provider-terms review approved.
|
|
||||||
2. Credential lifecycle and revocation drill documented and exercised.
|
|
||||||
3. Adapter contract tests pass for streaming, tools, cancellation, reasoning policy, errors, and audit correlation.
|
|
||||||
4. Tenant-bound authorization remains entirely in Mosaic Gateway.
|
|
||||||
5. Failure injection proves no duplicate side effects across retries or provider failover.
|
|
||||||
6. Rollback to the prior provider path is exercised.
|
|
||||||
7. Independent code and security reviews approve the exact deployed revision.
|
|
||||||
|
|
||||||
## Follow-up
|
|
||||||
|
|
||||||
- #754 owns cross-harness logical identity, checkpoint, receipt, adapter, and failover work.
|
|
||||||
- #755 / PR #757 implements the first logical identity and connector lease/fencing boundary.
|
|
||||||
- A later issue should prototype LiteLLM and Bifrost behind the provider adapter after #755 is merged and independently qualified.
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Channel Protocol Architecture
|
# Channel Protocol Architecture
|
||||||
|
|
||||||
**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative
|
**Status:** Draft
|
||||||
**Authors:** Mosaic Core Team
|
**Authors:** Mosaic Core Team
|
||||||
**Last Updated:** 2026-07-14
|
**Last Updated:** 2026-03-22
|
||||||
**Covers:** M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix)
|
**Covers:** M7-001 (IChannelAdapter interface), M7-002 (ChannelMessage protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,80 +11,93 @@
|
|||||||
|
|
||||||
The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels).
|
The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels).
|
||||||
|
|
||||||
The implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups:
|
The protocol consists of two main contracts:
|
||||||
|
|
||||||
1. `OfficialChannelAdapter` — transport lifecycle and connection health.
|
1. `IChannelAdapter` — the interface each channel driver must implement.
|
||||||
2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data.
|
2. `ChannelMessage` — the canonical message format that flows through the system.
|
||||||
3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address.
|
|
||||||
4. `ChannelResponseTargetDto` — channel/thread destination for replies.
|
|
||||||
|
|
||||||
All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter.
|
All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M7-001: OfficialChannelAdapter Interface
|
## M7-001: IChannelAdapter Interface
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface OfficialChannelAdapter {
|
interface IChannelAdapter {
|
||||||
/** Stable, lowercase adapter identifier such as "discord" or "matrix". */
|
/**
|
||||||
|
* Stable, lowercase identifier for this channel (e.g. "matrix", "discord").
|
||||||
|
* Used as a namespace key in registry lookups and log metadata.
|
||||||
|
*/
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
/** Establish both native-channel and gateway connections. */
|
|
||||||
start(): Promise<void>;
|
/**
|
||||||
/** Gracefully close connections and release resources. */
|
* Establish a connection to the external channel backend.
|
||||||
stop(): Promise<void>;
|
* Called once at application startup. Must be idempotent (safe to call
|
||||||
/** Best-effort health; ordinary disconnection is a result, not an exception. */
|
* when already connected).
|
||||||
health(): Promise<{
|
*/
|
||||||
status: 'connected' | 'degraded' | 'disconnected';
|
connect(): Promise<void>;
|
||||||
detail?: string;
|
|
||||||
}>;
|
/**
|
||||||
|
* Gracefully disconnect from the channel backend.
|
||||||
|
* Must flush in-flight sends and release resources before resolving.
|
||||||
|
*/
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current health of the adapter connection.
|
||||||
|
* Used by the admin health endpoint and alerting.
|
||||||
|
*
|
||||||
|
* - "connected" — fully operational
|
||||||
|
* - "degraded" — partial connectivity (e.g. read-only, rate-limited)
|
||||||
|
* - "disconnected" — no connection to channel backend
|
||||||
|
*/
|
||||||
|
health(): Promise<{ status: 'connected' | 'degraded' | 'disconnected' }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an inbound message handler.
|
||||||
|
* The adapter calls `handler` for every message received from the channel.
|
||||||
|
* Multiple calls replace the previous handler (last-write-wins).
|
||||||
|
* The handler is async; the adapter must not deliver new messages until
|
||||||
|
* the previous handler promise resolves (back-pressure).
|
||||||
|
*/
|
||||||
|
onMessage(handler: (msg: ChannelMessage) => Promise<void>): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a ChannelMessage to the given channel/room/conversation.
|
||||||
|
* `channelId` is the channel-native identifier (e.g. Matrix room ID,
|
||||||
|
* Discord channel snowflake, Telegram chat ID).
|
||||||
|
*/
|
||||||
|
sendMessage(channelId: string, msg: ChannelMessage): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a channel-native user identifier to the Mosaic internal userId.
|
||||||
|
* Returns null when no matching Mosaic account exists for the given
|
||||||
|
* channelUserId (anonymous or unlinked user).
|
||||||
|
*/
|
||||||
|
mapIdentity(channelUserId: string): Promise<string | null>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned.
|
|
||||||
|
|
||||||
### Stable conversation route
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ChannelConversationRouteDto {
|
|
||||||
bindingId: string;
|
|
||||||
logicalAgentId: string;
|
|
||||||
conversationId: string;
|
|
||||||
channelName: string;
|
|
||||||
authorizationChannelId: string;
|
|
||||||
responseTarget: { channelId: string; threadId?: string };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target.
|
|
||||||
|
|
||||||
### Typed ingress and egress ports
|
|
||||||
|
|
||||||
`ChannelIngressPort` is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same `ChannelIngressDto` before signing. The adapter uses a supplied `ChannelIngressPort` directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core.
|
|
||||||
|
|
||||||
`ChannelBindingDto` contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits `ChannelIngressDto` to `ChannelIngressPort.receive()`. It includes the normalized message, `ChannelAuthorizedPrincipalDto`, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port.
|
|
||||||
|
|
||||||
Gateway policy and runtime routing produce `ChannelEgressDto`, which `ChannelEgressPort.send()` delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics.
|
|
||||||
|
|
||||||
### Adapter Registration
|
### Adapter Registration
|
||||||
|
|
||||||
Adapters are registered with the gateway plugin host at startup. The host calls `start()`/`stop()` and may monitor `health()` on a configurable interval. A richer dynamic `ChannelRegistry` remains a compatible future extension of this lifecycle contract.
|
Adapters are registered with the `ChannelRegistry` service at startup. The registry calls `connect()` on each adapter and monitors `health()` on a configurable interval (default: 30 s).
|
||||||
|
|
||||||
```
|
```
|
||||||
ChannelRegistry
|
ChannelRegistry
|
||||||
└── register(adapter: OfficialChannelAdapter): void
|
└── register(adapter: IChannelAdapter): void
|
||||||
└── getAdapter(name: string): OfficialChannelAdapter | null
|
└── getAdapter(name: string): IChannelAdapter | null
|
||||||
└── listAdapters(): OfficialChannelAdapter[]
|
└── listAdapters(): IChannelAdapter[]
|
||||||
└── healthAll(): Promise<Record<string, AdapterHealth>>
|
└── healthAll(): Promise<Record<string, AdapterHealth>>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M7-002: ChannelMessageDto Protocol
|
## M7-002: ChannelMessage Protocol
|
||||||
|
|
||||||
### Canonical Message Format
|
### Canonical Message Format
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ChannelMessageDto {
|
interface ChannelMessage {
|
||||||
/**
|
/**
|
||||||
* Globally unique message ID.
|
* Globally unique message ID.
|
||||||
* Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
|
* Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
|
||||||
@@ -97,7 +110,6 @@ interface ChannelMessageDto {
|
|||||||
* The adapter populates this from the inbound message.
|
* The adapter populates this from the inbound message.
|
||||||
* For outbound messages, the caller supplies the target channel.
|
* For outbound messages, the caller supplies the target channel.
|
||||||
*/
|
*/
|
||||||
channelName: string;
|
|
||||||
channelId: string;
|
channelId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,7 +119,7 @@ interface ChannelMessageDto {
|
|||||||
senderId: string;
|
senderId: string;
|
||||||
|
|
||||||
/** Sender classification. */
|
/** Sender classification. */
|
||||||
senderKind: 'user' | 'agent' | 'system';
|
senderType: 'user' | 'agent' | 'system';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Textual content of the message.
|
* Textual content of the message.
|
||||||
@@ -124,7 +136,7 @@ interface ChannelMessageDto {
|
|||||||
* - "image" — binary image; content is empty, see attachments
|
* - "image" — binary image; content is empty, see attachments
|
||||||
* - "file" — binary file; content is empty, see attachments
|
* - "file" — binary file; content is empty, see attachments
|
||||||
*/
|
*/
|
||||||
contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file';
|
contentType: 'text' | 'markdown' | 'code' | 'image' | 'file';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arbitrary key-value metadata for channel-specific extension fields.
|
* Arbitrary key-value metadata for channel-specific extension fields.
|
||||||
@@ -132,7 +144,7 @@ interface ChannelMessageDto {
|
|||||||
* Adapters should store channel-native IDs here so round-trip correlation
|
* Adapters should store channel-native IDs here so round-trip correlation
|
||||||
* is possible without altering the canonical fields.
|
* is possible without altering the canonical fields.
|
||||||
*/
|
*/
|
||||||
metadata: Readonly<Record<string, ChannelMetadataValue>>;
|
metadata: Record<string, unknown>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional thread or reply-chain identifier.
|
* Optional thread or reply-chain identifier.
|
||||||
@@ -151,21 +163,18 @@ interface ChannelMessageDto {
|
|||||||
* Binary or URI-referenced attachments.
|
* Binary or URI-referenced attachments.
|
||||||
* Each attachment carries its MIME type and a URL or base64 payload.
|
* Each attachment carries its MIME type and a URL or base64 payload.
|
||||||
*/
|
*/
|
||||||
attachments?: readonly ChannelAttachmentDto[];
|
attachments?: ChannelAttachment[];
|
||||||
|
|
||||||
/** ISO-8601 wall-clock timestamp when the message was sent/received. */
|
/** Wall-clock timestamp when the message was sent/received. */
|
||||||
timestamp: string;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChannelAttachmentDto {
|
interface ChannelAttachment {
|
||||||
/** Channel-native attachment identifier. */
|
/** Filename or identifier. */
|
||||||
id: string;
|
|
||||||
|
|
||||||
/** Filename or display name. */
|
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
/** MIME type when supplied by the channel. */
|
/** MIME type (e.g. "image/png", "application/pdf"). */
|
||||||
mimeType: string | null;
|
mimeType: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL pointing to the attachment, OR a `data:` URI with base64 payload.
|
* URL pointing to the attachment, OR a `data:` URI with base64 payload.
|
||||||
@@ -183,18 +192,18 @@ interface ChannelAttachmentDto {
|
|||||||
|
|
||||||
## Channel Translation Reference
|
## Channel Translation Reference
|
||||||
|
|
||||||
The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`.
|
The following sections document how each supported channel maps its native message format to and from `ChannelMessage`.
|
||||||
|
|
||||||
### Matrix
|
### Matrix
|
||||||
|
|
||||||
| ChannelMessageDto field | Matrix equivalent |
|
| ChannelMessage field | Matrix equivalent |
|
||||||
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
|
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
|
||||||
| `channelId` | Matrix room ID (`!roomid:homeserver`) |
|
| `channelId` | Matrix room ID (`!roomid:homeserver`) |
|
||||||
| `senderId` | Matrix user ID (`@user:homeserver`) |
|
| `senderId` | Matrix user ID (`@user:homeserver`) |
|
||||||
| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
|
| `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
|
||||||
| `content` | `event.content.body` |
|
| `content` | `event.content.body` |
|
||||||
| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
|
| `contentType` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
|
||||||
| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` |
|
| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` |
|
||||||
| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` |
|
| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` |
|
||||||
| `attachments` | Populated from `url` in `m.image` / `m.file` events |
|
| `attachments` | Populated from `url` in `m.image` / `m.file` events |
|
||||||
@@ -207,58 +216,41 @@ The following sections document how each supported channel maps its native messa
|
|||||||
|
|
||||||
### Discord
|
### Discord
|
||||||
|
|
||||||
| ChannelMessageDto field | Discord equivalent |
|
| ChannelMessage field | Discord equivalent |
|
||||||
| ----------------------- | ----------------------------------------------------------------------- |
|
| -------------------- | ----------------------------------------------------------------------- |
|
||||||
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
|
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
|
||||||
| `channelId` | Discord channel ID (snowflake string) |
|
| `channelId` | Discord channel ID (snowflake string) |
|
||||||
| `senderId` | Discord user ID (snowflake) |
|
| `senderId` | Discord user ID (snowflake) |
|
||||||
| `senderKind` | `"user"` for human members; `"agent"` for bot messages |
|
| `senderType` | `"user"` for human members; `"agent"` for bot messages |
|
||||||
| `content` | `message.content` |
|
| `content` | `message.content` |
|
||||||
| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) |
|
| `contentType` | `"markdown"` (Discord uses a markdown-like syntax natively) |
|
||||||
| `threadId` | `message.thread.id` when the message is inside a thread channel |
|
| `threadId` | `message.thread.id` when the message is inside a thread channel |
|
||||||
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
|
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
|
||||||
| `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` |
|
| `attachments` | `message.attachments` mapped to `ChannelAttachment` |
|
||||||
| `timestamp` | `new Date(message.timestamp)` |
|
| `timestamp` | `new Date(message.timestamp)` |
|
||||||
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` |
|
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` |
|
||||||
|
|
||||||
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentKind = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
||||||
|
|
||||||
### Discord routing and thread policy
|
|
||||||
|
|
||||||
A configured Discord binding maps `(guildId, parentChannelId)` to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID.
|
|
||||||
|
|
||||||
| Inbound location/trigger | Conversation and response target |
|
|
||||||
| ------------------------------------------ | --------------------------------------------------------------- |
|
|
||||||
| Authorized untagged parent-channel message | Parent channel; response is sent in-channel |
|
|
||||||
| Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread |
|
|
||||||
| Authorized message already in a thread | Existing thread; no repeated mention and no nested thread |
|
|
||||||
| `/approve` or `/stop <approval>` | Current parent/thread durable session; no new topic is created |
|
|
||||||
|
|
||||||
Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target.
|
|
||||||
|
|
||||||
### Discord service ingress security
|
|
||||||
|
|
||||||
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
|
### Telegram
|
||||||
|
|
||||||
| ChannelMessageDto field | Telegram equivalent |
|
| ChannelMessage field | Telegram equivalent |
|
||||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
|
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
|
||||||
| `channelId` | Telegram `chat_id` (integer as string) |
|
| `channelId` | Telegram `chat_id` (integer as string) |
|
||||||
| `senderId` | Telegram `from.id` (integer as string) |
|
| `senderId` | Telegram `from.id` (integer as string) |
|
||||||
| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages |
|
| `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages |
|
||||||
| `content` | `message.text` or `message.caption` |
|
| `content` | `message.text` or `message.caption` |
|
||||||
| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
|
| `contentType` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
|
||||||
| `threadId` | `message.message_thread_id` (for supergroup topics) |
|
| `threadId` | `message.message_thread_id` (for supergroup topics) |
|
||||||
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
|
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
|
||||||
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` |
|
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` |
|
||||||
| `timestamp` | `new Date(message.date * 1000)` |
|
| `timestamp` | `new Date(message.date * 1000)` |
|
||||||
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` |
|
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` |
|
||||||
|
|
||||||
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
|
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -266,14 +258,14 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket
|
|||||||
|
|
||||||
The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel.
|
The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel.
|
||||||
|
|
||||||
| ChannelMessageDto field | TUI equivalent |
|
| ChannelMessage field | TUI equivalent |
|
||||||
| ----------------------- | ------------------------------------------------------------------ |
|
| -------------------- | ------------------------------------------------------------------ |
|
||||||
| `id` | Generated UUID (TUI has no native message IDs) |
|
| `id` | Generated UUID (TUI has no native message IDs) |
|
||||||
| `channelId` | `"tui:<conversationId>"` — the active conversation ID |
|
| `channelId` | `"tui:<conversationId>"` — the active conversation ID |
|
||||||
| `senderId` | Authenticated Mosaic `userId` |
|
| `senderId` | Authenticated Mosaic `userId` |
|
||||||
| `senderKind` | `"user"` for human input; `"agent"` for agent replies |
|
| `senderType` | `"user"` for human input; `"agent"` for agent replies |
|
||||||
| `content` | Raw text from stdin / agent output |
|
| `content` | Raw text from stdin / agent output |
|
||||||
| `contentKind` | `"text"` for input; `"markdown"` for agent responses |
|
| `contentType` | `"text"` for input; `"markdown"` for agent responses |
|
||||||
| `threadId` | Not used (TUI sessions are linear) |
|
| `threadId` | Not used (TUI sessions are linear) |
|
||||||
| `replyToId` | Not used |
|
| `replyToId` | Not used |
|
||||||
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
|
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
|
||||||
@@ -288,14 +280,14 @@ The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the chan
|
|||||||
|
|
||||||
The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`).
|
The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`).
|
||||||
|
|
||||||
| ChannelMessageDto field | WebUI equivalent |
|
| ChannelMessage field | WebUI equivalent |
|
||||||
| ----------------------- | ------------------------------------------------------------ |
|
| -------------------- | ------------------------------------------------------------ |
|
||||||
| `id` | Generated UUID; echoed back in the WebSocket event |
|
| `id` | Generated UUID; echoed back in the WebSocket event |
|
||||||
| `channelId` | `"webui:<conversationId>"` |
|
| `channelId` | `"webui:<conversationId>"` |
|
||||||
| `senderId` | Authenticated Mosaic `userId` |
|
| `senderId` | Authenticated Mosaic `userId` |
|
||||||
| `senderKind` | `"user"` for browser input; `"agent"` for agent responses |
|
| `senderType` | `"user"` for browser input; `"agent"` for agent responses |
|
||||||
| `content` | Message text from the input field |
|
| `content` | Message text from the input field |
|
||||||
| `contentKind` | `"text"` or `"markdown"` |
|
| `contentType` | `"text"` or `"markdown"` |
|
||||||
| `threadId` | Not used (conversation model handles threading) |
|
| `threadId` | Not used (conversation model handles threading) |
|
||||||
| `replyToId` | Message ID the user replied to (UI reply affordance) |
|
| `replyToId` | Message ID the user replied to (UI reply affordance) |
|
||||||
| `attachments` | Files uploaded via the file picker; stored to object storage |
|
| `attachments` | Files uploaded via the file picker; stored to object storage |
|
||||||
@@ -308,7 +300,7 @@ The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel prot
|
|||||||
|
|
||||||
## Identity Mapping
|
## Identity Mapping
|
||||||
|
|
||||||
Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity.
|
`mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts.
|
||||||
|
|
||||||
The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context).
|
The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context).
|
||||||
|
|
||||||
@@ -327,8 +319,8 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
|
|||||||
|
|
||||||
## Error Handling Conventions
|
## Error Handling Conventions
|
||||||
|
|
||||||
- `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready.
|
- `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s).
|
||||||
- `ChannelEgressPort.send()` implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried.
|
- `sendMessage()` must throw `ChannelSendError` on terminal failures (auth revoked, channel not found). Transient failures (rate limit, network blip) should be retried internally with exponential backoff before throwing.
|
||||||
- `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
|
- `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
|
||||||
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.
|
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.
|
||||||
|
|
||||||
@@ -336,7 +328,7 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
|
|||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
The `ChannelMessageDto` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
|
The `ChannelMessage` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
|
||||||
|
|
||||||
Current version: **1.0.0**
|
Current version: **1.0.0**
|
||||||
|
|
||||||
@@ -477,7 +469,7 @@ A single Mosaic conversation can be accessed simultaneously from multiple surfac
|
|||||||
### Real-Time Sync Flow
|
### Real-Time Sync Flow
|
||||||
|
|
||||||
1. A message arrives on any surface (TUI keystroke, browser send, Matrix event).
|
1. A message arrives on any surface (TUI keystroke, browser send, Matrix event).
|
||||||
2. The surface's adapter normalizes the message to `ChannelMessageDto` and delivers it to `ConversationService`.
|
2. The surface's adapter normalizes the message to `ChannelMessage` and delivers it to `ConversationService`.
|
||||||
3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`.
|
3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`.
|
||||||
4. All active surfaces subscribed to that `conversationId` receive the fanout event and push it to their respective clients:
|
4. All active surfaces subscribed to that `conversationId` receive the fanout event and push it to their respective clients:
|
||||||
- TUI adapter: writes rendered output to the connected terminal session.
|
- TUI adapter: writes rendered output to the connected terminal session.
|
||||||
@@ -565,7 +557,7 @@ Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessi
|
|||||||
- Their `channel_identities` row exists (link not revoked).
|
- Their `channel_identities` row exists (link not revoked).
|
||||||
- They remain members of the relevant Matrix rooms.
|
- They remain members of the relevant Matrix rooms.
|
||||||
|
|
||||||
Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) removes the `channel_identities` row and causes gateway principal resolution to deny the identity. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).
|
Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) removes the `channel_identities` row and causes `mapIdentity()` to return `null`. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -737,7 +729,7 @@ room_retention_policies
|
|||||||
created_at TIMESTAMP
|
created_at TIMESTAMP
|
||||||
```
|
```
|
||||||
|
|
||||||
The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessageDto` record unless the Mosaic retention policy also covers it.
|
The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessage` record unless the Mosaic retention policy also covers it.
|
||||||
|
|
||||||
Default retention values:
|
Default retention values:
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
|
||||||
|
|
||||||
## Boundary
|
|
||||||
|
|
||||||
M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The normalized identity is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
(tenant_id, logical_agent_id, binding_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
`logical_agent_id` is a server-owned stable identifier. A connector is a replaceable holder of a lease for one binding; it is not the agent identity.
|
|
||||||
|
|
||||||
## Durable lease model
|
|
||||||
|
|
||||||
PostgreSQL table `logical_agent_connector_leases` has one unique row per identity/binding tuple. The current row records:
|
|
||||||
|
|
||||||
- an opaque lease UUID;
|
|
||||||
- connector ID and normalized allowed scopes;
|
|
||||||
- a positive decimal fencing epoch stored as PostgreSQL `bigint`;
|
|
||||||
- acquired, heartbeat, expiry, release, and update timestamps.
|
|
||||||
|
|
||||||
Initial acquisition is insert-only. An existing active row causes `lease_held`. An expired or released row causes `takeover_required`; ordinary acquisition cannot recover it. Authorized takeover uses compare-and-swap against the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch.
|
|
||||||
|
|
||||||
The companion `connector_lease_audit_log` is append-only metadata. It stores lifecycle event, outcome/reason, identity/binding/connector, epoch, correlation ID, and timestamp. It deliberately excludes scopes, grant objects, payloads, approval references, tokens, and credentials.
|
|
||||||
|
|
||||||
## Execution grants
|
|
||||||
|
|
||||||
`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. Defense-in-depth caps leases at 5 minutes and grants at 30 seconds by default; constructor options may tighten these limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, and epoch.
|
|
||||||
|
|
||||||
Validation occurs immediately before adapter invocation and rereads PostgreSQL. The adapter receives only `ConnectorExecutionContext`; harness-native schemas remain behind the adapter. Validation denies:
|
|
||||||
|
|
||||||
- grants not minted by the current gateway process (including cloned/forged objects);
|
|
||||||
- expired grants or leases;
|
|
||||||
- released leases;
|
|
||||||
- stale epochs or replaced connector/lease UUIDs;
|
|
||||||
- missing/cross-tenant/cross-agent/cross-binding leases;
|
|
||||||
- scopes not authorized by both grant and current lease.
|
|
||||||
|
|
||||||
A gateway restart intentionally invalidates process-local grants. The durable lease and epoch survive, and a fresh grant may be issued only after current-lease and gateway-policy validation.
|
|
||||||
|
|
||||||
## Concurrency and side-effect rule
|
|
||||||
|
|
||||||
The database CAS determines the sole current holder. A successful takeover makes every old-epoch validation fail. Connector adapters must consume and propagate the normalized lease epoch/context so downstream effect boundaries can also fence races that occur after gateway validation.
|
|
||||||
|
|
||||||
M1 does not provide exactly-once receipts or a side-effect journal. Those remain later #754 work; callers must not infer exactly-once delivery from lease fencing.
|
|
||||||
|
|
||||||
## Extension boundary
|
|
||||||
|
|
||||||
`ConnectorLeaseService` is the gateway-owned policy surface. Every policy decision receives the normalized requested scopes and TTL (or explicit `null` where no TTL applies), so a concrete policy can enforce least privilege and duration limits. Its production default policy denies every lease/grant operation until a server-configured connector policy is supplied. No M1 HTTP endpoint accepts caller-controlled tenant or logical identity, and no concrete Claude/Pi/Codex adapter or channel cutover is included.
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# Fleet Configuration Management — Documentation IA Acceptance Checklist
|
|
||||||
|
|
||||||
**Issue:** #758 · **Scope:** M0 documentation gate for the local fleet declarative-configuration program.
|
|
||||||
|
|
||||||
This checklist is an acceptance contract for documentation and examples. It does not authorize
|
|
||||||
schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its
|
|
||||||
named artifact exists, is linked from the fleet documentation entry point, and its evidence is
|
|
||||||
recorded in the M0 task/PR.
|
|
||||||
|
|
||||||
## M0 baseline acceptance
|
|
||||||
|
|
||||||
- [ ] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd,
|
|
||||||
tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of
|
|
||||||
unsupported or quarantined legacy input.
|
|
||||||
- [ ] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but
|
|
||||||
does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader`
|
|
||||||
capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess
|
|
||||||
and Ultron remain configurable.
|
|
||||||
- [ ] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
|
|
||||||
observed state, including stopped-state preservation through migration, apply, and reboot.
|
|
||||||
- [ ] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary
|
|
||||||
command overrides in M1–M5, and requires key-name/hash-only quarantine diagnostics.
|
|
||||||
- [ ] `docs/PRD.md` identifies the M1–M5 local-tmux scope and excludes remote reconciliation,
|
|
||||||
connector mutation, secret references, arbitrary commands/channels, gateway convergence, and
|
|
||||||
UI configuration storage.
|
|
||||||
- [ ] `docs/TASKS.md` contains the complete M0–M5 one-card/one-PR dependency DAG for #758 with
|
|
||||||
agent tier, branch, dependency, estimate, and evidence expectations.
|
|
||||||
- [ ] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped
|
|
||||||
fleet example, profile, and service preset before M1 implementation starts.
|
|
||||||
|
|
||||||
## Required documentation IA for M1–M5
|
|
||||||
|
|
||||||
| Path | Minimum content | Delivery gate |
|
|
||||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
|
|
||||||
| `docs/fleet/README.md` | Fleet configuration entry point, desired-vs-observed decision tree, link map | M5 |
|
|
||||||
| `docs/fleet/concepts/desired-vs-observed-state.md` | SSOT/projection model, drift, generation and ownership | M5 |
|
|
||||||
| `docs/fleet/concepts/identity-class-runtime.md` | Stable name, display alias, class, runtime/provider/model separation | M5 |
|
|
||||||
| `docs/fleet/concepts/role-authority-and-leases.md` | Required roles, validator/merge-gate separation, lease limits | M5 |
|
|
||||||
| `docs/fleet/concepts/generated-env-launch-chain.md` | Generated/local files, precedence, quarantine and non-shell parsing | M5 |
|
|
||||||
| `docs/fleet/reference/roster-v2.schema.json` | Executable v2 structural contract | M1 |
|
|
||||||
| `docs/fleet/reference/roster-v2-fields.md` | Every field, default, constraint, compatibility behavior and examples | M1 |
|
|
||||||
| `docs/fleet/reference/cli.md` | `config`, `agent`, lifecycle, plan/apply, JSON and exit-code contracts | M2–M3 |
|
|
||||||
| `docs/fleet/reference/role-classes.md` | Canonical classes, aliases, authority matrix and instance-name rule | M1 |
|
|
||||||
| `docs/fleet/reference/lifecycle-transitions.md` | Create/start/stop/restart/apply/reboot/rollback transition table | M3 |
|
|
||||||
| `docs/fleet/reference/status-and-drift.md` | Desired/observed/managed state, orphans, revision mismatch, doctor output | M3 |
|
|
||||||
| `docs/fleet/how-to/create-update-delete-agent.md` | Safe CRUD, expected generation, dry-run and rollback | M2 |
|
|
||||||
| `docs/fleet/how-to/start-stop-restart.md` | Persisted versus one-shot lifecycle actions | M3 |
|
|
||||||
| `docs/fleet/how-to/configure-tess-interaction.md` | Configurable interaction instance; no hardcoded identity | M5 |
|
|
||||||
| `docs/fleet/how-to/configure-ultron-validator.md` | Configurable validator instance; no merge authority | M5 |
|
|
||||||
| `docs/fleet/how-to/customize-roles.md` | Existing baseline + `roles.local` resolution and validation | M1 |
|
|
||||||
| `docs/fleet/operations/reconcile-and-recover.md` | Plan/apply failure recovery, generation lock and canary rollout | M3 |
|
|
||||||
| `docs/fleet/operations/env-quarantine.md` | Legacy-key inventory, private quarantine and redaction behavior | M2 |
|
|
||||||
| `docs/fleet/operations/systemd-tmux-troubleshooting.md` | Socket ambiguity, ownership proof, systemd/tmux drift | M3 |
|
|
||||||
| `docs/fleet/operations/backup-restore.md` | Roster/projection backup and rollback boundaries | M4 |
|
|
||||||
| `docs/fleet/operations/upgrade-assets.md` | Source-vs-installed asset revision detection and safe refresh | M5 |
|
|
||||||
| `docs/fleet/migration/v1-to-v2.md` | Normative field map, observed-state preservation and rollback | M4 |
|
|
||||||
| `docs/fleet/migration/example-profile-disposition.md` | Final disposition of every shipped example/profile | M1–M4 |
|
|
||||||
| `docs/fleet/migration/legacy-class-aliases.md` | Alias, unresolved-class, and retirement rules | M1 |
|
|
||||||
|
|
||||||
## PRD acceptance-criteria mapping
|
|
||||||
|
|
||||||
| PRD acceptance criterion | Owning card(s) | Required evidence |
|
|
||||||
| ------------------------------------------------------------ | ---------------------------------- | --------------------------------------------------------------------------------------- |
|
|
||||||
| `AC-FCM-01` schema, semantic validation, canonical rendering | FCM-M1-001, FCM-M1-002 | YAML/JSON positive/negative and schema/parser/resolver parity tests |
|
|
||||||
| `AC-FCM-02` deterministic plan and no-mutation check | FCM-M3-001 | Stable JSON/exit-code and desired-versus-observed fixture tests |
|
|
||||||
| `AC-FCM-03` safe generation-guarded CRUD | FCM-M2-002 | Create/update/delete idempotency, expected-generation, dry-run, and recovery tests |
|
|
||||||
| `AC-FCM-04` generated/local boundary and quarantine | FCM-M2-001 | Launch-chain, shadow, injection, redaction, and forbidden-key tests |
|
|
||||||
| `AC-FCM-05` lifecycle/reconcile/socket/drift safety | FCM-M3-001, FCM-M3-002 | Isolated systemd/tmux, stopped-state, orphan, socket, and rollback evidence |
|
|
||||||
| `AC-FCM-06` v1 migration and example/profile disposition | FCM-M4-001, FCM-M4-002, FCM-M1-003 | Preview/canary/rollback fixture plus executable disposition inventory |
|
|
||||||
| `AC-FCM-07` authority and lease boundaries | FCM-M1-002 | Role/authority/lease denial tests and resolved role contracts |
|
|
||||||
| `AC-FCM-08` documentation and final release gate | FCM-M5-001, FCM-M5-002 | Checklist closure, link/example validation, reviews, certificate, and terminal-green CI |
|
|
||||||
|
|
||||||
## Cross-cutting evidence gates
|
|
||||||
|
|
||||||
- [ ] Every retained or migrated YAML/JSON example, profile, and service preset validates through the
|
|
||||||
same executable schema and shared baseline-plus-`roles.local` resolver used by the CLI.
|
|
||||||
- [ ] Every retired example/profile/service preset has a replacement link and deprecation note; no
|
|
||||||
unresolved legacy class or tool-policy alias remains silently shipped.
|
|
||||||
- [ ] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded
|
|
||||||
Tess/Ultron identity.
|
|
||||||
- [ ] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed
|
|
||||||
`mosaic agent` catalog.
|
|
||||||
- [ ] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of
|
|
||||||
legacy sensitive keys are never printed.
|
|
||||||
- [ ] M5 release review verifies links, schema/example validation, and that all checklist rows have
|
|
||||||
owner/evidence or an explicit approved deferral.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Fleet Configuration Management — Legacy Example, Profile, and Service Disposition Inventory
|
|
||||||
|
|
||||||
**Issue:** #758 · **Baseline:** `origin/main` `49e8a541` · **Status:** M0 inventory; no source
|
|
||||||
examples or profiles are changed by this document.
|
|
||||||
|
|
||||||
The v2 compiler may not silently accept an unresolved class. Before M1 exits, every shipped file
|
|
||||||
below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or
|
|
||||||
retired with a replacement/deprecation note. Class resolution must use the existing
|
|
||||||
profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a
|
|
||||||
parallel resolver. The current executable implementation and per-artifact outcomes are recorded in
|
|
||||||
[the disposition evidence](./migration/example-profile-disposition.md).
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence |
|
|
||||||
| ---------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
||||||
| `framework/fleet/examples/coding.yaml` | `orchestrator`, `enhancer`, `implementer`, `reviewer` | Migrate: `implementer → code`, `reviewer → review`; retain orchestration/enhancer intent | v2 fixture validates; role aliases and authority matrix tested |
|
|
||||||
| `framework/fleet/examples/general.yaml` | `orchestrator`, `enhancer`, `worker` | Migrate only after operator chooses a concrete canonical role for `worker`; no implicit conversion | Explicit replacement class, or versioned v1 fixture/retirement note |
|
|
||||||
| `framework/fleet/examples/hybrid.yaml` | `orchestrator`, `enhancer`, `implementer`, `researcher`, `reviewer` | Migrate aliases; resolve `researcher` through existing role resolver or retain/version | Shared resolver validation; no ad-hoc class scanner |
|
|
||||||
| `framework/fleet/examples/local-canary.yaml` | `orchestrator`, `implementer`, `reviewer` | Migrate aliases; preserve its local-tmux canary purpose | v2 fixture validates and preserves safe stopped/running behavior |
|
|
||||||
| `framework/fleet/examples/minimal.yaml` | `canary` | Retire or version as v1 unless an existing canonical role contract is selected deliberately | Replacement link/deprecation note or CI-valid v1 fixture |
|
|
||||||
| `framework/fleet/examples/operator-interaction.yaml` | `operator-interaction` | Migrate alias to `interaction`; preserve instance/display name as configuration, not schema identity | v2 interaction fixture validates; no Tess literal is required |
|
|
||||||
| `framework/fleet/examples/research.yaml` | `orchestrator`, `enhancer`, `researcher`, `analyst` | Resolve `researcher`/`analyst` through baseline + `roles.local`, or version/retire | Resolver evidence and explicit disposition for each unresolved class |
|
|
||||||
|
|
||||||
## Profiles
|
|
||||||
|
|
||||||
| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence |
|
|
||||||
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
|
|
||||||
| `framework/fleet/profiles/business.yaml` | `ceo`, `coo`, `cfo`, `product-manager`, `marketing-lead`, `sales-lead`, `operations-manager`, `customer-success-manager`, `code`, `review` | Retain only if every class resolves through the existing role library/`roles.local`; otherwise version/retire rather than weakening validation | Shared resolver CI result for every class; documented role source or replacement |
|
|
||||||
| `framework/fleet/profiles/marketing.yaml` | `marketing-lead`, `content-strategist`, `copywriter`, `seo-specialist`, `social-media-manager`, `brand-strategist`, `growth-marketer`, `ux-designer` | Same resolver-or-version/retire rule | Per-class resolver CI result and replacement/deprecation record if unresolved |
|
|
||||||
| `framework/fleet/profiles/personal-assistant.yaml` | `personal-assistant`, `executive-assistant`, `scheduler`, `inbox-manager`, `researcher` | Same resolver-or-version/retire rule | Per-class resolver CI result; do not infer `interaction` equivalence |
|
|
||||||
| `framework/fleet/profiles/research.yaml` | `lead-researcher`, `researcher`, `data-analyst`, `data-scientist`, `market-analyst`, `documentation`, `review` | Same resolver-or-version/retire rule | Per-class resolver CI result and explicit compatibility posture |
|
|
||||||
| `framework/fleet/profiles/software-delivery.yaml` | `orchestrator`, `board`, `planner`, `decomposition`, `code`, `review`, `security-review`, `site-tester`, `documentation`, `merge-gate`, `rebase`, `operator`, `session-review`, `enhancer` | Retain as the governance reference; add `validator`, `team-leader`, and `interaction` only through approved role/profile work, not silent substitution | CI validates all current classes; separate fixture proves required M1 authority seats |
|
|
||||||
|
|
||||||
## Service presets
|
|
||||||
|
|
||||||
| Shipped file | Current policy evidence | M0 disposition decision | Required M1/M4 evidence |
|
|
||||||
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `framework/fleet/services/operator-interaction.yaml` | Generic policy only: `runtime: pi`, `model: openai/gpt-5.6-sol`, `reasoning: high`, `tool_policy: operator-interaction`; provisioning supplies the agent name as data | Retain as a generic service policy, not a Tess identity. Migrate `tool_policy: operator-interaction` only through the approved interaction tool-policy alias/semantic resolver; do not infer a class or machine name from this file. | Service-policy fixture validates runtime/model/reasoning and alias behavior; generic provisioning proves a configured interaction instance is supplied without a hardcoded Tess name. |
|
|
||||||
|
|
||||||
## Required disposition controls
|
|
||||||
|
|
||||||
1. **No silent aliasing:** only `implementer → code`, `reviewer → review`, and
|
|
||||||
`operator-interaction → interaction` are approved deterministic aliases in this M0 baseline.
|
|
||||||
`worker`, `analyst`, `canary`, and domain-specific classes require resolver evidence or an
|
|
||||||
explicit version/retirement decision.
|
|
||||||
2. **No identity hardcoding:** Tess and Ultron are optional instance/display names. An example/profile
|
|
||||||
may demonstrate the capability but must not make a product name a required class or machine ID.
|
|
||||||
3. **No lifecycle inference from an example:** examples describe desired configuration only; migration
|
|
||||||
of an installed v1 roster separately preserves observed stopped/running state.
|
|
||||||
4. **No secret or command migration:** examples/profiles must not introduce credential values or
|
|
||||||
`MOSAIC_AGENT_COMMAND`; those legacy keys are M2 quarantine inputs, never v2 authoring fields.
|
|
||||||
5. **Service presets are included:** service policies are inventoried alongside examples/profiles.
|
|
||||||
They may express launch/tool policy, but do not create a class, a canonical agent identity, or a
|
|
||||||
second validation path.
|
|
||||||
6. **Evidence is executable:** M1/M4 CI must enumerate these exact files, validate retained/migrated
|
|
||||||
inputs through the shared resolver, and fail if a file lacks its documented disposition.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Customize Fleet Roles
|
|
||||||
|
|
||||||
Mosaic resolves persona contracts through two layers:
|
|
||||||
|
|
||||||
1. `fleet/roles/<canonical-class>.md` — seeded baseline contract.
|
|
||||||
2. `fleet/roles.local/<canonical-class>.md` — operator override or custom role; this layer wins.
|
|
||||||
|
|
||||||
The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation,
|
|
||||||
and launch-time persona injection.
|
|
||||||
|
|
||||||
## Override a baseline role
|
|
||||||
|
|
||||||
Create a readable Markdown contract under `roles.local` with the canonical filename and class marker:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Code — local role definition
|
|
||||||
|
|
||||||
The local code role (`class: code`) follows the operator's repository conventions.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save it as `fleet/roles.local/code.md`. Do not edit generated or seeded baseline assets when the goal
|
|
||||||
is a durable local customization.
|
|
||||||
|
|
||||||
Legacy aliases canonicalize before lookup. Therefore `roles.local/implementer.md` does not override
|
|
||||||
`code`; use `roles.local/code.md`. See [Legacy Fleet Class Aliases](../migration/legacy-class-aliases.md).
|
|
||||||
|
|
||||||
## Add a custom class
|
|
||||||
|
|
||||||
A custom class remains supported when a readable contract exists for the exact identifier:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Release notes — local role definition
|
|
||||||
|
|
||||||
The release-notes role (`class: release-notes`) prepares operator-reviewed release copy.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save it as `fleet/roles.local/release-notes.md`, then reference `class: release-notes` and a matching
|
|
||||||
`tool_policy: release-notes` in roster v2. Adding only a `LIBRARY.md` row is insufficient.
|
|
||||||
|
|
||||||
Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom
|
|
||||||
contracts. `agents[].alias`, Tess, and Ultron are display names and cannot select a class.
|
|
||||||
|
|
||||||
## Validation and authority boundaries
|
|
||||||
|
|
||||||
Semantic validation reads the winning contract and rejects missing, unreadable, or empty files.
|
|
||||||
Protected authority is derived from canonical class metadata in code, never from role prose. A custom
|
|
||||||
contract cannot claim merge, validation-certificate, orchestration, lease, or interaction authority.
|
|
||||||
|
|
||||||
Roster v2 also fails closed when a protected class and tool policy do not match after canonicalization,
|
|
||||||
or when an unprotected class claims a protected tool policy. The legacy `operator-interaction` policy
|
|
||||||
canonicalizes to `interaction`.
|
|
||||||
|
|
||||||
Role customization does not issue leases, store validation certificates, mutate credentials, or
|
|
||||||
change lifecycle state.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Executable Fleet Example, Profile, and Service-Preset Dispositions
|
|
||||||
|
|
||||||
**Issue:** #758 · **Card:** FCM-M1-003 · **Status:** M1 executable disposition evidence
|
|
||||||
|
|
||||||
This document records the executable disposition for every currently shipped fleet YAML artifact.
|
|
||||||
The authoritative baseline classification remains the
|
|
||||||
[legacy inventory](../LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md). The executable guard is
|
|
||||||
`packages/mosaic/src/fleet/example-profile-dispositions.ts`; its test fails if a shipped YAML
|
|
||||||
artifact is added, removed, or left without one of the dispositions below.
|
|
||||||
|
|
||||||
## Disposition rules
|
|
||||||
|
|
||||||
- **Explicit v1 fixture:** the artifact is loaded through the existing v1 roster parser and must
|
|
||||||
declare `version: 1`. It remains a compatibility fixture; it is not silently treated as a v2
|
|
||||||
roster or given inferred aliases.
|
|
||||||
- **Canonical profile:** the artifact is loaded through `loadProfiles`, which uses the shared
|
|
||||||
baseline-plus-`roles.local` persona resolver and rejects unreadable or unresolved classes.
|
|
||||||
- **Canonical service policy:** the artifact is loaded through the operator-interaction service
|
|
||||||
policy reader and provisioned with a generic supplied identity. It validates its runtime, model,
|
|
||||||
reasoning, and legacy tool-policy compatibility without hardcoding a product identity.
|
|
||||||
|
|
||||||
No artifact is retired in this card. A later retirement requires both a replacement link and a
|
|
||||||
visible deprecation note; the executable guard must then record the new disposition before the
|
|
||||||
artifact can be removed.
|
|
||||||
|
|
||||||
## Shipped artifacts
|
|
||||||
|
|
||||||
| Artifact | Disposition | Executable path | Compatibility notes |
|
|
||||||
| ------------------------------------ | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
||||||
| `examples/coding.yaml` | Explicit v1 fixture | v1 roster parser | Retains approved `implementer` and `reviewer` compatibility inputs. |
|
|
||||||
| `examples/general.yaml` | Explicit v1 fixture | v1 roster parser | Retains unresolved `worker` without an inferred canonical role. |
|
|
||||||
| `examples/hybrid.yaml` | Explicit v1 fixture | v1 roster parser | Retains `implementer`, `reviewer`, and resolver-dependent `researcher`. |
|
|
||||||
| `examples/local-canary.yaml` | Explicit v1 fixture | v1 roster parser | Retains the local-tmux canary topology. |
|
|
||||||
| `examples/minimal.yaml` | Explicit v1 fixture | v1 roster parser | Retains `canary` without an inferred canonical role. |
|
|
||||||
| `examples/operator-interaction.yaml` | Explicit v1 fixture | v1 roster parser | Keeps Tess only as an example instance name; `operator-interaction` remains compatibility input. |
|
|
||||||
| `examples/research.yaml` | Explicit v1 fixture | v1 roster parser | Retains resolver-dependent `researcher` and `analyst`. |
|
|
||||||
| `profiles/business.yaml` | Canonical profile | shared profile/persona resolver | Every referenced business class must resolve to a readable contract. |
|
|
||||||
| `profiles/marketing.yaml` | Canonical profile | shared profile/persona resolver | Every referenced marketing class must resolve to a readable contract. |
|
|
||||||
| `profiles/personal-assistant.yaml` | Canonical profile | shared profile/persona resolver | No interaction equivalence is inferred. |
|
|
||||||
| `profiles/research.yaml` | Canonical profile | shared profile/persona resolver | Every research class must resolve to a readable contract. |
|
|
||||||
| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. |
|
|
||||||
| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. |
|
|
||||||
|
|
||||||
## Running the guard
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
The guard is intentionally limited to shipped assets and validation. It does not generate
|
|
||||||
environment files, mutate a roster, reconcile a fleet, migrate an installed roster, or launch an
|
|
||||||
agent.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Legacy Fleet Class Aliases
|
|
||||||
|
|
||||||
Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy
|
|
||||||
class names and converts them to canonical classes before persona lookup:
|
|
||||||
|
|
||||||
| Legacy value | Canonical value | Migration action |
|
|
||||||
| ---------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `implementer` | `code` | Replace class and tool-policy references with `code`. |
|
|
||||||
| `reviewer` | `review` | Replace class and tool-policy references with `review`. |
|
|
||||||
| `operator-interaction` | `interaction` | Replace class and roster-v2 tool-policy references with `interaction`. The legacy service artifact remains compatible. |
|
|
||||||
|
|
||||||
Alias support preserves existing inputs while provisioning and typed semantic output use canonical
|
|
||||||
identities. Requested and canonical class values remain separately observable during semantic
|
|
||||||
validation.
|
|
||||||
|
|
||||||
## Lookup and override behavior
|
|
||||||
|
|
||||||
Canonicalization precedes baseline and `roles.local` lookup. A legacy-named override such as
|
|
||||||
`roles.local/implementer.md` is not a separate authority and is not selected for an `implementer`
|
|
||||||
request. Customize the canonical role instead, for example `roles.local/code.md`.
|
|
||||||
|
|
||||||
The compatibility file `operator-interaction.md` remains shipped, but `interaction` is the canonical
|
|
||||||
role class. Tess is an example display name only.
|
|
||||||
|
|
||||||
## Unresolved and custom classes
|
|
||||||
|
|
||||||
No names are inferred from historical usage, instance names, or similar wording. `worker`, `analyst`,
|
|
||||||
`canary`, Tess, and Ultron are not aliases. An otherwise unknown class is accepted only if the shared
|
|
||||||
resolver can read an actual baseline or `roles.local` contract for that exact class. A `LIBRARY.md`
|
|
||||||
row without a readable contract fails semantic validation.
|
|
||||||
|
|
||||||
Custom classes receive no protected authority implicitly. Protected class/tool-policy mismatches
|
|
||||||
fail closed.
|
|
||||||
|
|
||||||
## Retirement guidance
|
|
||||||
|
|
||||||
New configuration should emit canonical values. Existing inputs may use the three aliases during the
|
|
||||||
compatibility period, but operators should migrate class and tool-policy fields together. Do not
|
|
||||||
create new legacy-named role overrides; move their intended content to the canonical filename and
|
|
||||||
validate the roster/profile before removing the old artifact.
|
|
||||||
61
docs/fleet/proposals/mosaic-platform-prd/DEBATE-FINDINGS.md
Normal file
61
docs/fleet/proposals/mosaic-platform-prd/DEBATE-FINDINGS.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Debate Findings & Dispositions — Mosaic Platform PRD
|
||||||
|
|
||||||
|
> **Convener:** mos-claude-1 · **Date:** 2026-07-09 · **Panel:** 9 personas × 2 rounds (8 Claude lenses + independent Codex runtime), 20 agents, ~1.05M tokens
|
||||||
|
> **Artifacts:** `jarvis-brain:docs/scratchpads/mosaic-platform-prd-debate/` (THREAD.md — full transcript · SYNTHESIS.md — Principal-Engineer close-out)
|
||||||
|
> **Mandate (Jason, 2026-07-09):** "debate and make judgment calls." D1–D12 were held fixed; the panel attacked only the implementing structure. Dispositions below are the convener's judgment calls, folded into the sibling docs in this directory. Items marked **OPEN — Jason** need his call at ratification.
|
||||||
|
|
||||||
|
## How to read this
|
||||||
|
|
||||||
|
Every synthesis finding (SYNTHESIS.md §1, items 1–24) is dispositioned here. **Accepted** findings are folded into the PRDs/YAML as inline fixes or "Debate-accepted deltas" rows; this file is the traceability record. Severity labels are the panel's (P0 blocker → P3 note).
|
||||||
|
|
||||||
|
## P0 findings — all accepted, folded inline
|
||||||
|
|
||||||
|
| # | Finding | Disposition |
|
||||||
|
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | Storage-authority contradiction (X2 "relational + flat-file backends" vs Q "Postgres sole record") | **Accepted.** X2 rewritten: Postgres authoritative for all product entities; flat-file backend is a derived, regenerated, **read-only projection**. |
|
||||||
|
| 2 | Phase-1 Jarvis executes external PA ops before the relay exists (J2 vs NS-11) | **Accepted.** J2 split: **J2a** workspace-internal entities (phase 1) / **J2b** external integrations (phase 2, `depends_on: [P2]`). Phase-1 has no external credential path at all. |
|
||||||
|
| 3 | `phase` vs `depends_on` disagree; dispatcher obedience undefined | **Accepted.** Phases encoded as real DAG edges (`X2 depends_on [J2a, P2]`, J2b gate above); sandbox dispatch-test added to ratification checklist (README Gate Zero §). |
|
||||||
|
| 4 | Four load-bearing upstream artifacts unverified (memory subsystem, Hermes-MCP tool equivalents, push pipeline, wake/event router) | **Accepted.** README gains **Gate Zero** (pre-ratification artifact audit); presumed-MISSING rows get goal cards now: **M1** (memory subsystem), **J6** (event/wake router), **K3** (push pipeline). Parity map's MCP row re-pointed at concrete deliverables. |
|
||||||
|
| 5 | X-R4 migration manifest wrong about its own source tree (phantom `tickets.json`, unlisted dirs, six divergent memory stores, live Vikunja sync unmapped) | **Accepted.** X-R4 rewritten: migrator stage 1 = machine-generated source census (incl. untracked paths + all memory stores), per-path disposition, any `unknown` blocks; re-point list generated from `tools/sync_*.py`; Vikunja disposition line added to X-R6. |
|
||||||
|
|
||||||
|
## P1 findings — all accepted
|
||||||
|
|
||||||
|
| # | Finding | Disposition (folded as deltas) |
|
||||||
|
| --- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 6 | Events have no provider join key; re-point duplicates the calendar | **Accepted.** Provider-sourced events migrate **from the provider**; flat files supply only brain-native events. DST/recurrence fixture is the AC (X-R6 delta). |
|
||||||
|
| 7 | Approval pipeline has no state machine (double-execute / approve≠execute) | **Accepted.** P deltas: CAS on one durable row, terminal states, consumed-event dedupe table (shared substrate with bridge + wake dedupe), payload persisted at request time, poll/ack outcomes, per-capability retry class, staleness bound, `approved_unexecuted` alarm, re-surface = re-**prepare** with machine diff. |
|
||||||
|
| 8 | "Cannot bypass by construction" false: agent-readable vault creds + host-resident creds outside the relay | **Accepted.** Credentials gateway-only + scoped capability tokens; P1 gains host credential inventory with continuous scheduled scan in the health floor; clean-host AC passes with empty exemption list. |
|
||||||
|
| 9 | Silent auto-deny steady state (fail-closed TTL + single surface + unprovisioned push) | **Accepted.** K gains homeserver ops + monitored push (K3); CLI approval surface ships **with** P2; delivered/seen tracking + TTL/2 escalation; `denied` / `expired_seen` / `expired_unseen` distinct terminal states. |
|
||||||
|
| 10 | Prompt injection → durable memory; wake turns add system-role injection | **Accepted.** J2 write-side trust rule (transitive `source_trust=external`; standing-instruction-shaped content needs user ratification before retrievable); wake turns templated with provenance-tagged data fields. |
|
||||||
|
| 11 | Exactly-one-Jarvis has no fencing incl. the Matrix send path | **Accepted.** New **J-R16** workspace lease `(workspace_id, epoch)` CAS row in product Postgres; epoch on every write; pre-send lease re-check; takeover notice; degraded = mute-with-notice. NS-10 amended: one main agent per **workspace**. |
|
||||||
|
| 12 | Matrix principal resolution undefined (Codex #2, unanswered in R2) | **Accepted.** K/P delta: immutable Matrix user ID + bridge provenance + workspace membership → product principal; unlinked/bridged-unlinked identities read-only, cannot approve/butt-in/trigger external writes. All four Codex fixtures = deny + audit. |
|
||||||
|
| 13 | Policy evaluation time undefined (Codex #7, unanswered in R2) | **Accepted.** Immutable policy snapshot on every prepared action/card; execution revalidates or fails `policy_changed`; delegated effects gated by grant **intersection**. |
|
||||||
|
|
||||||
|
## P2/P3 findings — accepted (see per-PRD delta sections)
|
||||||
|
|
||||||
|
14 Hermes evidence snapshot **before** stop (machine gate) · 15 Q1 crash barriers + `external_refs` unique-index table (one mechanism, three consumers) · 16 rollback honestly scoped (transport-only, point-of-no-return = first native card) + bounded day-30 review with three recorded outcomes · 17 human-attention budget (rate limits, quotas, deferrable flag, away state) · 18 self-referential-loop containment (provenance labels, source-grounded retrieval preference, retrieval eval gate ≥50 queries / ≥90% baseline recall@5 + negative queries, tombstones, priority budget, day-1 trend telemetry) · 19 butt-in exclusive lease + structured control-plane API + break-glass doctrine · 20 default-closed capability gating with `unclassified` third state · 21 per-agent atomic approval-routing cutover table · 22 `needs-decision` card lifecycle (immutable spec + typed amendments; `ratified_by` authorizes dispatch, never merge) · 23 retention class per durable table; dedupe pruning checkpoint-coupled · 24 AC-NS-8 made measurable (distinct quota pools pinned in profile; TTFT p95 ≤ 1.2× baseline, ≥30 interleaved turns).
|
||||||
|
|
||||||
|
**All accepted.** None conflicts with D1–D12.
|
||||||
|
|
||||||
|
## Open disagreements — convener judgment calls
|
||||||
|
|
||||||
|
| § | Question | Call |
|
||||||
|
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 3.1 | Gate architecture: tiered blocking pack (Moonshot+Ops+ML-Cal) vs 4 artifact-existence machine gates + pass/fail-free pre-registered dossier (Contrarian) | **Adopted the agreed floor now** (4 machine gates on irreversible transitions, pre-registration, priced amendments, day-1 emission — folded into X). Superstructure choice is **OPEN — Jason**. Convener recommendation: **Contrarian's dossier + dumb gates** — the tier demonstrably gamed itself within one debate round (13+ claims vs cap 10, slot-riding, zero demotions); simple machine gates don't degrade under pressure. |
|
||||||
|
| 3.2 | Audit schema: additive-only typed schema (Coder-Data) vs six-field mandatory envelope + typed payloads + pinned checked-in queries (Contrarian) | **Adopted: minimal envelope + schema-on-read** (folded into P1 delta). Rationale: preserves "can't add data later" essentials without a god-schema by committee; an additive typed layer can be grown later where query pain proves it. |
|
||||||
|
| 3.3 | Unclassified capability: reject at call time vs version-scoped activation hold vs capability-scoped hold | **Adopted: capability-scoped hold** (Contrarian R2#6a, synthesis editor concurs) — gate the capability, ship the version; security patches are never pinned behind classification. |
|
||||||
|
| 3.4 | Away mode: fail-closed expiry labeled `expired_during_away` vs suppress preparation while away | **Adopted: suppress preparation** of non-deferrable requests while away + audited suppressed-preparations list swept on return. Cleaner than labeling corpses; nothing expires that was never surfaced. |
|
||||||
|
| 3.5 | Memory-exclusion scope for measurement artifacts | **Adopted: normative/parametric split** (Contrarian R2#4) — rules the agent is scored against stay retrievable; thresholds/seeds/drill timings/canary templates are excluded. |
|
||||||
|
| 3.6 | Statistical instruments | **Adopted:** deterministic named crash barriers as the gate; residual randomized soak is **trace-directed**, not wall-clock-uniform. |
|
||||||
|
| 3.7 | K2 scope narrowing by Hermes traffic audit | **Adopted as a Gate Zero action:** run the audit pre-ratification; platforms with live traffic become the must-have subset gating X3. |
|
||||||
|
|
||||||
|
## Process rules adopted (README)
|
||||||
|
|
||||||
|
- **Gate Zero** — pre-ratification upstream-artifact audit (`present @ SHA` or MISSING → goal card).
|
||||||
|
- **Conflict register** — spec contradictions block the requirement, not the mission ("alert, don't auto-resolve" promoted to specs).
|
||||||
|
- **DoD line** on every goal card (runbook, health-floor alerts, AGENTS.md).
|
||||||
|
- **Silent-roster rule** — a panel member's silent round records their open findings as open items, never consensus (Codex's R2 silence on #2/#7 is the instance; both were folded as P1 items 12–13 above, explicitly not consensus-resolved).
|
||||||
|
|
||||||
|
## Addendum — logging & telemetry (Jason, 2026-07-09, post-debate)
|
||||||
|
|
||||||
|
Requirement raised outside the panel, folded in the same pass: Mosaic Stack must support **inbound error reporting/logging** from agents and installs, plus **optional anonymous agentic-efficiency telemetry** — no IP or PII capture, opt-in. The P0 of this capability already exists: **MALS** (Mosaic Agent Log System, FastAPI+Postgres, `~/src/mals`), currently dark because its k3s migration landed without an Ingress (tracked: infrastructure #135). Folded as new **workstream L** (L1 restore MALS · L2 Mosaic-native ingestion · L3 anonymous telemetry) + standing objective **NS-14**. Day-1 trial-metric emission (finding 18) targets MALS until W3 panels exist.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# PRD — Backlog Provider Sync Adapters · Workstream Q
|
||||||
|
|
||||||
|
> **Status:** DRAFT for ratification · **Goals:** Q1–Q3 · **Doctrine:** NS-12 (ratified D3)
|
||||||
|
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Users choose where they _see and touch_ work — Gitea, GitHub, a local kanban — while the **Mosaic Backlog on native Postgres stays the sole record and dispatch engine** (upholds ASM-1; NS-3/NS-4/NS-5 guarantees never depend on an external provider). Providers attach as bidirectional sync adapters.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Adapter interface + Gitea (Q1)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Q-R1 | A `BacklogProviderAdapter` interface: map card ⇄ external item (create/update/close/comment/label), with stable external-id linkage stored on the card. |
|
||||||
|
| Q-R2 | Sync is bidirectional and conflict-safe: native record wins on divergence; external edits arrive as proposed mutations (applied if non-conflicting, else surfaced). |
|
||||||
|
| Q-R3 | Claims, TTLs, depends_on DAG, and dispatch state live **only** in the native record; adapters project them (e.g. as labels/comments) but never own them. |
|
||||||
|
| Q-R4 | Gitea adapter first (webhook + API), configured per workspace: repo mapping, label conventions, direction (mirror-out / mirror-in / full). |
|
||||||
|
| Q-R5 | Adapter enable/disable is workspace configuration; zero adapters is a fully supported mode. |
|
||||||
|
|
||||||
|
### GitHub (Q2)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Q-R6 | Same interface, GitHub Issues backend. Existing `packages/cli-tools` platform detection informs but does not implement this (that is dev tooling, not product runtime). |
|
||||||
|
|
||||||
|
### Local kanban (Q3)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Q-R7 | A webUI kanban board over the native backlog (no external provider needed) — the "local kanban" choice. Builds on W3's card views and/or the existing `KanbanBoard` component upgraded from demo-grade to live data. |
|
||||||
|
|
||||||
|
## Debate-accepted deltas (2026-07-09) — normative
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Q-R8 | **External-ref linkage is one shared mechanism:** a unique-indexed `external_refs(entity_id, system, external_id)` table serves adapter linkage (Q-R1), migration idempotency (X-R4 anti-join), and re-point verification (X-R6) — built once, three consumers. Idempotent upsert; a violated unique index is a converge signal, never an overwrite. |
|
||||||
|
| Q-R9 | **Crash-safe external creates:** the adapter writes a `pending-link` row **before** any external create and embeds a deterministic card-id marker in the created item, so a crash between create and link-back is recovered by scan, never by duplicate creation. |
|
||||||
|
| Q-R10 | **Echo-loop guard:** both directions carry revision counters; adapter-authored external edits are tagged (marker/actor) and skipped on read-back. A sync cycle that would re-import its own write is a hard test failure. |
|
||||||
|
| Q-R11 | The sync engine is a **level-triggered reconciler** over desired-vs-observed state (same doctrine as J6), not a webhook-only event chase: webhooks accelerate convergence, the reconciler guarantees it. Missed webhooks are a latency event, not a correctness event. |
|
||||||
|
| Q-R12 | Card spec immutability (J-R20) projects cleanly: the mirrored issue body is the pinned spec revision; amendments append as **ordered provider comments**, never body rewrites, so external watchers see the same amendment history as the native record. |
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. A card created by Jarvis (J3) appears as a Gitea issue within one sync interval; closing the issue in Gitea marks the card for review, not silent closure; dispatch/claims never round-trip through Gitea.
|
||||||
|
2. Killing the adapter mid-mission: dispatch continues unaffected (record is native); on restart, sync converges without duplicates.
|
||||||
|
3. The same mission can be mirrored to Gitea and viewed on the local kanban simultaneously without state divergence.
|
||||||
|
4. **Named crash barriers** at every external-call boundary (`before_external_create`, `after_create_before_link`, `after_link_before_ack`): kill the adapter at each; zero duplicate external items, zero orphaned cards. CI rule: a new external call site without a named barrier + kill test **fails the build**. Residual randomized soak is trace-directed (§3.6 disposition).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- External provider AS the backlog (vetoed — "truly swappable backends" option declined 2026-07-09).
|
||||||
|
- Two-way sync of claims/TTL semantics (external systems can't express them; projection only).
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- ASSUMPTION: the delivery fleet's _engineering_ PR/issue flow on the stack repo itself continues to use `cli-tools`/Gitea directly — workstream Q is the product feature for user workspaces, not a replacement for the dev workflow.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# PRD — Hermes Decommission & Tenant-1 Migration · Workstream X
|
||||||
|
|
||||||
|
> **Status:** DRAFT for ratification · **Goals:** X1–X3 · **Doctrine:** NS-13, ASM-8 (Hermes untouched until verified parity)
|
||||||
|
> Ratified direction (D2, 2026-07-09): Mosaic absorbs **all four** Hermes functions — messaging bridge, task board, permission relay, multi-platform reach.
|
||||||
|
> **Debate pass 2026-07-09:** panel findings folded — this PRD took the heaviest rewrite (storage authority, migration census, memory-store hygiene, honest rollback scope). See `DEBATE-FINDINGS.md`.
|
||||||
|
|
||||||
|
## Deployment scope (D12/ASM-9)
|
||||||
|
|
||||||
|
The trial runs in the **homelab**. Hermes and the primitive-era stack (`mos-claude.service`, jarvis-brain boards) live in the **USC/web1 environment**, which is untouched during the trial. This workstream therefore lands in two stages: **X-in-homelab** (prove parity where the fleet is native — mainly K/P/Q verification plus tenant-1 migration) and **X-at-USC** (post-trial adoption: apply the parity checklist to web1, migrate Mos-on-web1 to `mosaic-agent@orchestrator`, then decommission Hermes there, with `/src/infrastructure` GitOps updates in the same delivery set).
|
||||||
|
|
||||||
|
**Trial go/no-go (D12/ASM-9 gate):** the homelab→USC promotion is **owner-judgment**, not an automated metric. The stage gate is: _Jason instantiates and operates the split-agent stack in the homelab and is satisfied with its operation._ Only on that explicit sign-off does X-at-USC begin. The capability ACs (AC-NS-8…11) are the evidence Jason weighs; they inform the decision but do not auto-trigger USC deployment.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Retire Hermes entirely. Mosaic becomes the platform for transport (Matrix connector), task board (native backlog + webUI/adapters), approvals (permission relay), and multi-platform reach (mautrix bridges). In the same arc, Jason's jarvis-brain flat-file data migrates into the product as **tenant #1**, making the product's PA feature set the dogfooded default.
|
||||||
|
|
||||||
|
## Parity map (what replaces what)
|
||||||
|
|
||||||
|
| Hermes function | Mosaic replacement | Workstream |
|
||||||
|
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||||
|
| Messaging bridge (Discord/Telegram/…) | Matrix connector + mautrix bridges | K1, K2 |
|
||||||
|
| Kanban / task board | Native Mosaic Backlog + webUI board + provider adapters | A\*, Q, W3 |
|
||||||
|
| Permission relay (`permissions_*`) | Guard-rails engine + approval queue (Matrix + webUI) | P1–P3 |
|
||||||
|
| Cross-platform user reach | mautrix bridges (agents speak Matrix only) | K2 |
|
||||||
|
| Hermes MCP tools in agent sessions | **Per-tool equivalence table** (Gate Zero artifact): approvals → P2, board ops → Q1/A\*, messaging → K1 — not a generic "gateway API" gesture | P2, Q1, K1 |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Parity checklist + cutover plan (X1)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| X-R1 | A written, testable parity checklist per row above; each item verified in production before its Hermes counterpart is disabled. The checklist is seeded from a **pre-trial usage audit**: per-MCP-tool and per-capability call counts over a trailing window, plus an inventory of Hermes-held provider callbacks/webhooks and secrets. Parity rows with `n < 5` real invocations in the window cannot be "verified by traffic" — they get **scheduled drills in weeks 1–3** of the observation window instead of silent green. |
|
||||||
|
| X-R1a | **Approval-routing cutover is per-agent atomic:** a cutover table states, per agent, the single moment its `permissions_*` path flips from Hermes to the P relay. No agent ever has two live approval paths; no approval window where neither path is live. |
|
||||||
|
| X-R2 | Cutover is staged with rollback at every stage; Hermes runs untouched until AC-NS-11 is verified (ASM-8). **Rollback is honestly scoped (debate #16): it restores _transport_ (Hermes services + MCP registrations) — board/approval state created natively during the trial does NOT back-migrate.** The **point of no return is the first native-only card**; the runbook says so explicitly, and the abort path (below) is written before cutover, not during an incident. |
|
||||||
|
| X-R3 | The Matrix charter's live-cutover rules apply: stated window, announce before/after, rollback ready. |
|
||||||
|
|
||||||
|
### Tenant-1 migration (X2)
|
||||||
|
|
||||||
|
**Framing (ratified 2026-07-09, storage authority clarified by debate P0 #1):** jarvis-brain **is the P0 (prototype) Mosaic Stack** — its flat-file data layer is the zeroth implementation of what the product does properly. Migration is therefore _P0 → proper Mosaic Stack_. **Native Postgres is the sole authoritative store for every product entity** (consistent with workstream Q's "sole record" doctrine and NS-3/NS-4/NS-5). A flat-file backend, where offered, is a **derived, regenerated, read-only projection** — the same relationship generated views have to JSON in the P0 today — never a co-equal write target. Two distinct data classes migrate — they are not the same destination and neither is frozen:
|
||||||
|
|
||||||
|
- **(a) PA data** (projects, tasks, events, tickets, knowledge) → product entities in the Jason workspace (Project/Task/Event/KnowledgeEntry), authoritative in Postgres; flat-file export available as a read-only projection.
|
||||||
|
- **(b) Agent memory & operational knowledge** (runbooks, digests, scratchpads, OpenBrain thoughts) → the **enhanced memory subsystem (goal M1: vector DB + memory service)**. This flow stays **live and writable** throughout — it was never Hermes and must not be frozen by the PA cutover.
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| X-R4 | **Stage 1 of the migrator is a machine-generated source census, not a hand-written file list** (debate P0 #5 — the hand list was already wrong about its own tree). The census walks the live repo (tracked + untracked), inventories every data path — `data/**`, all memory stores (`memory/`, `memories/`, `memory_store*`, `memory.md`, `brain.jsonl`, `jarvis.db`, digests), scratchpads, notes, prior-generation artifacts — and assigns each a disposition: `migrate-as-PA(entity type)` / `migrate-as-memory(M1 class)` / `derived-regenerate` / `retire-with-history`. **Any path dispositioned `unknown` blocks the run.** Stage 2 migrates per-disposition, preserving source ids in metadata; idempotency via anti-join on the shared `external_refs` table (Q-R8); named crash barriers at each phase boundary. A **field-map table** covers the full `brain.py` query surface (status, progress, due, priority, domain, notes, staleness) → product entity fields, so AC 2 is checkable field-by-field. |
|
||||||
|
| X-R5 | Dry-run mode with a diffable report **generated from the census** (counts per disposition, per-entity field mapping, unmapped-field list — must be empty or explicitly waived); Jason ratifies the report before the real run (canonical-data gate — this is the one migration step that is his call). |
|
||||||
|
| X-R6 | External sync jobs (GLPI, Google Calendar, ICS, Gmail, **Vikunja — disposition decided here: re-point or retire, not silently dropped**) each get a **written per-integration transition protocol**: freeze flat-file sync job → verify product integration live → re-point → verify → retire old job. **Provider-sourced events migrate FROM the provider, not from flat files** (flat files supply only brain-native events) — the provider join key is authoritative, so re-pointing cannot duplicate the calendar (debate #6). Calendar fixture (Codex): a DST-crossing recurring event with one moved and one cancelled occurrence, plus an all-day event, round-trips with zero duplicates and correct local times. |
|
||||||
|
| X-R7 | Agent memory/operational knowledge (b) is migrated into the M1 memory subsystem **before** any jarvis-brain retirement; the memory write path stays continuously available (no read-only freeze of an active substrate). The census classifies every memory item: `ratified` / `superseded` / `draft` / `rejected` / `debate-artifact` / `protocol-normative` / `protocol-parametric`. **Parametric measurement artifacts (thresholds, seeds, drill timings, canary templates) are excluded from embedding** (§3.5 disposition — rules the agent is scored against stay retrievable; the knobs do not). Superseded/rejected items get **tombstones**, and supersession triggers re-embedding of affected summaries. **Retirement gate: a retrieval eval — ≥50 representative queries, ≥90% of baseline recall@5, plus negative queries (rejected/superseded content must NOT surface) — passes against M1 before any flat-file store goes read-only.** After cutover: write paths to retired stores are killed and a CI lint fails any reintroduction. Only once **both** (a) and (b) are migrated and verified is the jarvis-brain repo retired read-only (history preserved); generated views and brain.py retire. This closes the P0 prototype. |
|
||||||
|
|
||||||
|
### Decommission (X3)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| X-R8 | **Pre-stop evidence snapshot is a machine gate:** before Hermes stops, a checksummed snapshot of its state (board items, pending approvals, bridge registrations, per-tool usage counts) is captured and stored with the trial artifacts. The drill schedule (X-R1) and the credential-revocation report both **cite the snapshot checksum** — no snapshot, no stop. Then: services stopped, disabled, and removed from infra (GitOps: `/src/infrastructure` updated in the same delivery set); credentials revoked — the revocation report cites a **final scan** showing zero live references; MCP registrations removed from agent runtimes. |
|
||||||
|
| X-R9 | **Bounded day-30 review** between stop and removal, pre-registered before cutover (dossier: what will be measured, panels cited, drill results attached — see W-R15/L1 for where the metrics live, `stack_version`-segmented so mid-window upgrades don't blur rates). The review records exactly one of three outcomes: **promote** (remove Hermes), **extend with named blockers** (each blocker a card with an owner), or **abort** (execute the pre-written abort runbook; transport-only rollback per X-R2). "Insufficient data" is a recordable outcome that forces extend — never a shrug into promote. Any in-window regression flips back per X-R2. |
|
||||||
|
|
||||||
|
## Machine gates on irreversible transitions (debate §3.1 agreed floor)
|
||||||
|
|
||||||
|
Four **artifact-existence gates** — dumb, checkable, ungameable — sit on the irreversible transitions. Each is "the artifact exists and passes its check", not a scored rubric:
|
||||||
|
|
||||||
|
1. **Pre-stop snapshot** exists with valid checksum (X-R8) — gates Hermes stop.
|
||||||
|
2. **Census with zero `unknown` rows** exists (X-R4) — gates the PA migration run.
|
||||||
|
3. **Retrieval eval pass record** exists (X-R7) — gates memory-store retirement.
|
||||||
|
4. **All parity rows green-or-drilled** (X-R1: verified by traffic or by scheduled drill; no silent low-n green) — gates Hermes removal at day-30.
|
||||||
|
|
||||||
|
The gate _superstructure_ beyond this floor (tiered blocking pack vs pre-registered dossier) is **OPEN — Jason** at ratification; convener recommendation in `DEBATE-FINDINGS.md` §3.1.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. AC-NS-11: with Hermes stopped, no fleet or main-agent capability regresses.
|
||||||
|
2. `python tools/brain.py today`'s information content is fully answerable by Jarvis from the product workspace post-X2, verified field-by-field against the X-R4 field-map table.
|
||||||
|
3. Zero references to Hermes MCP tools in any active agent runtime config after X3.
|
||||||
|
4. The X-R6 calendar fixture (DST-crossing recurrence, moved + cancelled occurrence, all-day event) round-trips with zero duplicates.
|
||||||
|
5. The X-R7 retrieval eval passes against M1 before any memory store goes read-only; negative queries return no superseded/rejected content.
|
||||||
|
6. All four machine gates above have their artifacts on record before their respective transitions execute.
|
||||||
|
|
||||||
|
## Sequencing note
|
||||||
|
|
||||||
|
X depends on the longest chains (K1→K2, P2, Q1, J2a→J2b, **M1** — X2 cannot complete class (b) without the memory subsystem existing). Dependencies are real DAG edges in `north-star-additions.yaml` (`X2 depends_on [J2a, P2, M1]`), not prose phases (debate P0 #3). Expected order of value delivery: J1–J4 (Jarvis on existing transport interim) → K1/J5 (Matrix room) → P2, W1–W3, Q1 in parallel → X1 checklist → X2 migration → K2 bridges → X3 decommission.
|
||||||
|
|
||||||
|
- ASSUMPTION (interim transport): until K1 lands, Jarvis may run against the tmux connector (CLI/`agent send`) rather than standing up any Discord channel — keeps D1 (Matrix-first, no #jarvis Discord) intact.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# PRD — HMI Main Agent ("Jarvis") · Workstream J
|
||||||
|
|
||||||
|
> **Status:** DRAFT for ratification · **Source of truth once landed:** NORTH_STAR.yaml goals J1–J6
|
||||||
|
> **Depends on upstream:** H2 (system-type profiles), A3a (card lifecycle), B1 (supervisor tick), F4/K1 (Matrix connector)
|
||||||
|
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md` for dispositions.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Every Mosaic **workspace** gets exactly one always-on human-machine-interface agent — default alias **Jarvis**, unit `mosaic-agent@main.service` — that owns the human relationship: conversation, idea development, schedule, email, tasks, knowledge. It delegates all engineering/research/ops work to the orchestrator (**Mos**, `mosaic-agent@orchestrator.service`) through the Mosaic Backlog, and reports fleet status to the user without ever interrupting the orchestrator.
|
||||||
|
|
||||||
|
Jarvis is a **Level-0 orchestrator**: it accomplishes its own work through _delegation and subagents_, never by executing coding/infra tasks itself. PA mutations (tasks/events/knowledge) are direct API calls; everything heavier is either a spawned subagent (research, drafting, analysis) or a backlog card handed to Mos (engineering/infra/fleet). This keeps the main agent's context conversational and light.
|
||||||
|
|
||||||
|
This solves the observed failure mode: a busy orchestrator that can't respond, accumulates conversational context rot, and derails over time. Post-split, the orchestrator's context is execution-only.
|
||||||
|
|
||||||
|
Because Jarvis and Mos are **separate agents with separate model capacity** (D11: Jarvis on Opus, Mos on Fable; independent inference quota), orchestrator load cannot degrade conversational latency — the isolation in AC-NS-8 is a capacity guarantee, not merely a separate process.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Persona & runtime (J1)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| J-R1 | Jarvis is provisioned from the personal-assistant persona baseline via system-type profiles (H2/H3); alias, model tier, host, and channel are profile fields, not code. |
|
||||||
|
| J-R2 | Default model tier **Opus** (ratified D11); the orchestrator's tier is independent. Always-_available_ ≠ always-_billed_: Opus is provisioned 24/7 but cost is per-interaction — an idle Jarvis (no user turn in flight) incurs no model spend, so "always-on" carries no standing token bill. |
|
||||||
|
| J-R3 | Jarvis survives reboot under systemd (`mosaic-agent@main`), participates in the fleet heartbeat protocol, and is counted in the supervisor's health floor. |
|
||||||
|
| J-R4 | Persona customization is update-surviving per H4 (override layer wins on merge). |
|
||||||
|
| J-R16 | **Workspace lease (exactly-one fencing):** Jarvis acquires `workspace_lease(workspace_id, epoch)` — a CAS row in product Postgres — before processing any turn. Every PA write, card, and approval carries the epoch; stale-epoch writes are rejected server-side; the connector re-checks the lease immediately before every outbound send. Takeover posts an in-room/in-channel epoch notice. Degraded mode (lease unobtainable) = **mute-with-notice**, never conversational-while-unfenced. Clean shutdown releases the lease. This primitive also excludes homelab/USC split-brain during adoption. |
|
||||||
|
| J-R17 | Per-agent spend metering with a daily budget alarm for `mosaic-agent@main`; a main-agent crash-loop is a distinct, escalated supervisor condition (not a generic restart count). |
|
||||||
|
| J-R18 | **Resume protocol (promoted from open item):** resume context is reconstructed from authoritative queries (board, heartbeats, workspace entities), with narrative summary layered on top; a session-start divergence check flags contradictions between narrative and authoritative state. Jarvis is never re-instantiated from its own lossy summaries alone. |
|
||||||
|
|
||||||
|
### PA toolchain (J2a workspace-internal · J2b external)
|
||||||
|
|
||||||
|
**Split (debate P0 #2):** phase-1 Jarvis operates only on workspace-internal entities — **no external-write credential path exists** until the permission relay (P2) is live. External integrations arrive in phase 2 as J2b, `depends_on: [J2a, P2]`. Test: in a phase-1 deployment, `email:send` is _impossible_ (no credential provisioned), not merely unapproved.
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| J-R5 | (J2a) Jarvis executes personal-assistant mutations **directly** in the user's workspace via the product API: tasks, events/calendar, knowledge entries, ideas. No delegation for PA ops (ratified D4). |
|
||||||
|
| J-R6 | (J2b) External PA integrations (email, external calendars, helpdesk) are workspace-scoped integrations; **raw credentials are held exclusively by the gateway** — Jarvis receives scoped capability tokens, never provider secrets; actions flagged `requires_approval` route through the permission relay (workstream P). |
|
||||||
|
| J-R7 | Until tenant-1 migration (X2) completes, Jarvis may read/write the jarvis-brain flat files as a transitional adapter; the adapter is deleted after the **last verified X-R6 re-point** (not at a nominal "X2 cutover" date). A per-phase, per-entity-type source-of-truth table in the J1 profile states which store is authoritative at every moment. |
|
||||||
|
| J-R19 | **Write-side trust rule:** externally-sourced content (email bodies, bridged messages, webhook payloads) written into workspace entities or memory inherits `source_trust=external` **transitively through summarization**. Standing-instruction-shaped external content requires explicit user ratification before it becomes retrievable. This closes the injection→durable-memory channel. |
|
||||||
|
|
||||||
|
### Delegation contract (J3)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| J-R8 | The Jarvis→Mos handoff is **only** via Mosaic Backlog cards: goal, acceptance criteria, priority, depends_on, advisory budget. Never via chat messages to the orchestrator. |
|
||||||
|
| J-R9 | Jarvis translates conversation outcomes into card sets; ambiguity is resolved with the user _before_ card creation — the orchestrator receives only decision-complete work. |
|
||||||
|
| J-R10 | Card authorship is attributed (author=main-agent, ratified-by=user where applicable) for audit. |
|
||||||
|
| J-R11 | Authority line: Mos holds all execution and merge authority (NS-4). Jarvis relays the user's GO/NO-GO gates as card state, and never acquires fleet mutation, merge, or dispatch rights. `ratified_by=user` authorizes **dispatch only** — it never substitutes for the reviewer-of-record merge gate. |
|
||||||
|
| J-R20 | Card sets are drafted then **published atomically** with client idempotency keys (no partial card sets on crash). Card spec is **immutable after publish**; changes arrive as typed, ordered amendments with a revision counter; reviewer sign-offs pin the spec revision; scope-expanding amendments re-enter ratification. |
|
||||||
|
| J-R21 | **`needs-decision` lifecycle:** a worker hitting genuine ambiguity sets `needs-decision(question, options)` on the card **with a durable checkpoint** (pushed branch + card note) — resume after days is a re-dispatch, not a context continuation. Jarvis relays the question to the user and writes the answer back as an amendment. This is the sanctioned clarification path; J-R8's no-chat rule stands. |
|
||||||
|
| J-R22 | Jarvis-authored cards draw from a **priority budget** (quota per priority class per window) — priority inflation by the card author degrades the field for the whole fleet and is structurally capped, not policed by review. |
|
||||||
|
|
||||||
|
### Passive observability (J4)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| J-R12 | Jarvis answers "what's the fleet doing" from read-only sources: heartbeat files, `mosaic fleet ps` JSON, backlog card states, CI status. Zero messages to the orchestrator for status. |
|
||||||
|
| J-R13 | Jarvis proactively surfaces to the user: blocked cards, failed CI on user-ratified missions, approval requests pending, budget advisories. (PDA-friendly phrasing per SOUL.md.) Delivered via the **J6 event/wake router** — per-agent polling is forbidden. |
|
||||||
|
| J-R23 | **Event/wake router (J6):** one shared, level-triggered reconciler over durable state (heartbeats, card states, approval queue) wakes Jarvis on state _change_ with hysteretic per-condition suppression (wake once, then only on change or declared backoff; suppression survives restarts). Wake turns are **templated** — fixed instruction frame, workspace data only in delimited, provenance-tagged data fields (closes the injection→system-role channel). Idempotent on source event id via the shared consumed-events table. Router is in the health floor; its cost model and latency bound are stated in the J6 card. Reconciles J-R2: an idle Jarvis costs nothing _because waking is event-driven, not poll-driven_. |
|
||||||
|
|
||||||
|
### Channel (J5)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| J-R14 | **Phase 2 (target channel):** Jarvis's conversation lives in a dedicated Matrix room on the self-hosted homeserver via `OrchestratorConnector(matrix)` (K1 = f4 Phase 2). Matrix-first: no Discord channel is created for Jarvis (ratified D1). |
|
||||||
|
| J-R14a | **Phase 1 (interim channel, ratified):** Jarvis runs on the **tmux/CLI connector** — the f4 Phase-1 default connector. The operator launches the `mosaic-agent@main` tmux session and issues `/remote-control` to grant interactive access; this is the day-one conversation surface. No Discord, no Matrix dependency in Phase 1 (keeps D1 intact and unblocks J1–J4 before K1 lands). |
|
||||||
|
| J-R15 | Multi-platform user reach arrives via mautrix bridges (K2); Jarvis's code path is Matrix-only (from Phase 2 onward). |
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. AC-NS-8 **(made measurable)**: distinct credential/quota pools for Jarvis and Mos are pinned in the J1 profile; scripted suite of ≥30 interleaved turns under full orchestrator load; TTFT p95 ≤ 1.2× idle baseline with bootstrap CI; orchestrator receives zero conversational traffic.
|
||||||
|
2. AC-NS-9: a conversationally-agreed mission round-trips (cards → drained → completed → reported by Jarvis) with no chat handoff.
|
||||||
|
3. Kill the orchestrator mid-conversation: Jarvis conversation is unaffected; Jarvis reports the outage from heartbeat state. (Directly exercises the separate-capacity guarantee.)
|
||||||
|
4. `!sys`-equivalent admin verbs work in Jarvis's active channel — the tmux/CLI session in Phase 1, the Matrix room in Phase 2 (status/logs/clear/restart of the main agent).
|
||||||
|
5. **Phase-1 channel:** operator launches the `mosaic-agent@main` tmux session, issues `/remote-control`, and holds a full conversation with Jarvis over CLI with no Matrix/Discord dependency.
|
||||||
|
6. **Fencing:** start a second `mosaic-agent@main` by hand — it fails to acquire the workspace lease, posts a notice, and stays mute; zero duplicate writes or cards reach the workspace (J-R16).
|
||||||
|
7. **Phase-1 credential surface:** audit of a phase-1 install finds no external-provider credential readable by the Jarvis runtime (J2a/J2b split holds by construction).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Jarvis executing code/infra changes (that is Mos + fleet).
|
||||||
|
- Horizontal sharding of the main agent (rejected in the Matrix charter: split-brain).
|
||||||
|
- Per-workspace fleets (post-MVP per ASM-6).
|
||||||
|
|
||||||
|
## Open items (for Mos's planner)
|
||||||
|
|
||||||
|
- ~~Context hygiene / resume protocol~~ — promoted to J-R18 by the 2026-07-09 debate pass.
|
||||||
|
- Reconcile the old `apps/api` matrix-bot-sdk workspace bridge with the F4 connector design (one Matrix stack, not two). NOTE (verified 2026-07-09): no matrix dependency remains in `apps/api` on `origin/main` — this item is likely already moot; confirm before K1 build.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# PRD — Permission Relay · Workstream P
|
||||||
|
|
||||||
|
> **Status:** DRAFT for ratification · **Goals:** P1–P3
|
||||||
|
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`. The relay was the panel's densest target; the deltas below are normative.
|
||||||
|
> **Design origin (historical):** `docs/3-architecture/guard-rails-capability-permissions.md` — the "prepare freely, execute with approval" snapshot. **Not present on `origin/main`** (survives only in the stale `/src/mosaic-stack` clone), so its essential model is folded into this PRD below; **this document is the authoritative, self-contained spec for P.**
|
||||||
|
> **Replaces:** Hermes `permissions_list_open` / `permissions_respond` relay (Hermes exit prerequisite, NS-13)
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
A human-in-the-loop approval mechanism for agent actions: any capability listed as `requires_approval` is prepared by the agent, queued, and executed only after an explicit human approve — from the Matrix room or the webUI. Today this exists only as a bare `applyGuardRails()` method; Hermes currently fills the gap and must be replaced before decommission.
|
||||||
|
|
||||||
|
## Design model (folded in — the authoritative spec, since the origin snapshot is off-main)
|
||||||
|
|
||||||
|
**Doctrine — "prepare freely, execute with approval":** an agent may plan, draft, and stage any action without friction; only the _committing_ step of a `requires_approval` capability blocks on a human decision.
|
||||||
|
|
||||||
|
**Permission levels (least→most):** `read` → `organize` → `draft` → `execute` → `admin`. A capability grant names a level; `requires_approval` gates the transition into `execute`/`admin` for the capabilities a workspace marks sensitive.
|
||||||
|
|
||||||
|
**Grant shape:** `resource:action` (e.g. `email:send`, `git:push_main`, `dns:update`), scoped per workspace and per agent-persona, stored as configuration (profile field) so a user tightens/loosens without a code change.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Guard-rails engine (P1)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P-R1 | Capabilities are `resource:action` grants (e.g. `email:send`, `git:push_main`, `dns:update`) with permission levels (read / organize / draft / execute / admin) per the existing design doc. |
|
||||||
|
| P-R2 | Each integration declares its `requires_approval` list; grants are workspace-scoped and per-agent-persona. |
|
||||||
|
| P-R3 | Enforcement sits in the gateway/API dispatch path — an agent cannot bypass it by construction; bypass attempts are audited and denied. |
|
||||||
|
| P-R4 | Policy is configuration (profile field), honoring the configurability pillar: a user can tighten/loosen per capability without code change. |
|
||||||
|
|
||||||
|
### Approval queue + chat approvals (P2)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P-R5 | A pending approval is a durable queue record: requesting agent, capability, human-readable intent summary, prepared payload reference, TTL. |
|
||||||
|
| P-R6 | Approve/deny from the Matrix room (message action or reply verb); the requesting agent is notified of the outcome and proceeds/aborts. |
|
||||||
|
| P-R7 | Timeout = deny (fail-closed), with **per-capability TTLs** and distinct terminal states: `denied` / `expired_seen` / `expired_unseen` — agents must not reason about an expiry as a human "no". Deny and timeout leave the system unchanged. |
|
||||||
|
| P-R8 | Full audit trail: who approved what, when, from which surface (AC-NS-10). |
|
||||||
|
| P-R9 | The main agent (Jarvis) surfaces pending approvals conversationally (J-R13) but approval authority is the human's — Jarvis never auto-approves. |
|
||||||
|
|
||||||
|
### webUI surface (P3)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P-R10 | Pending-approval queue view in `apps/web` with one-click approve/deny, filterable per workspace/agent (depends W3 dashboard shell). |
|
||||||
|
|
||||||
|
## Debate-accepted deltas (2026-07-09) — normative
|
||||||
|
|
||||||
|
### State machine & delivery (extends P-R5–P-R7)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P-R11 | Approval lifecycle is a **CAS state machine on one durable row**: `pending → approved \| denied \| expired_seen \| expired_unseen`, all terminal. Concurrent surfaces (Matrix, webUI, CLI, TTL reaper) contend via compare-and-swap; exactly one wins. |
|
||||||
|
| P-R12 | The **prepared payload is persisted in the record at request time** — never a reference to requesting-agent process state (the agent may be dead when approval lands). Outcome delivery is **poll/ack**, not push-only; `approved_unexecuted > N min` raises an alarm. |
|
||||||
|
| P-R13 | A **consumed-event dedupe table** (shared substrate with bridged-message and wake-turn dedupe — built once, three consumers) makes approval consumption idempotent under Matrix at-least-once replay. Dedupe retention is **checkpoint-coupled**: events older than the durable sync token are dropped before lookup, so pruning never reopens the replay window. |
|
||||||
|
| P-R14 | Each capability declares `retry: safe \| at-most-once`; a prepare→execute **staleness bound** rejects execution of stale payloads. Re-surfacing an expired item **re-prepares** (new linked record with a rendered machine diff against the original) — never re-queues a stale payload. |
|
||||||
|
| P-R15 | **CLI approval surface** (`mosaic approvals list\|approve\|deny`) ships **with P2** as must-have — the newest infra (Matrix) is never the only approval path. Per-request delivered/seen tracking; TTL/2 escalation via a second path. |
|
||||||
|
|
||||||
|
### Identity & policy (closes Codex #2/#7)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| P-R16 | **Principal resolution:** every inbound approve/deny maps to a product principal via immutable Matrix user ID + bridge provenance + workspace membership. Unlinked identities and bridged puppets without an account link converse read-only and **cannot approve, butt-in, or trigger external writes**. Fixtures: bridged puppet, renamed user, invited non-admin, removed-member-with-lagging-room-membership — all deny + audit with reason. |
|
||||||
|
| P-R17 | **Policy snapshot:** every prepared action and card stores an immutable snapshot (profile id/version, grants evaluated). Execution **revalidates against current policy** or fails with explicit `policy_changed`. Profile changes are audited with schema validation + dry-run impact report. Delegated work (subagent, Jarvis-authored card) executes under the **intersection** of originator and executor grants. |
|
||||||
|
|
||||||
|
### Credential boundary (makes P-R3 true)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P-R18 | Raw provider credentials are held **exclusively by the gateway**; agents receive scoped capability tokens; the gateway injects secrets server-side. If the agent runtime can read the provider secret, P-R3 is decoration. |
|
||||||
|
| P-R19 | P1 delivers a **fleet-host credential inventory**: every host-resident credential (SSH keys, kubeconfigs, tool tokens) classified `moved-behind-gateway` or `explicitly-exempt(reason, owner, expiry)`. Enforced by a **continuous scheduled scan** of agent-readable paths (alert on unclassified), registered in the health floor. The clean-host install AC passes with an **empty exemption list**. Break-glass is one standing exempt row (owner: operator; audited post-hoc). |
|
||||||
|
|
||||||
|
### Gating defaults & load (closes default-open + human-overdraw)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P-R20 | **Default-closed inversion:** `execute`/`admin` capabilities on external integrations require approval **unless** workspace-allowlisted. Third state `unclassified`: a capability with no classification **rejects** at the gate. Classification happens at integration-version activation, scoped to the **capability, not the version** — a security patch bundling one new capability ships same-day; only the new capability rejects until classified. Manifest-less capabilities reject + integrity alert. |
|
||||||
|
| P-R21 | Rate limits + `max_pending_per_agent_per_capability`; queue depth and human decision latency are exported metrics with thresholds (rubber-stamping guard). Capabilities carry a `deferrable` flag; a declared **away state** pauses deferrable TTLs, batches pings, and **suppresses preparation** of non-deferrable requests (suppressed list swept, priority-ordered and paginated, on return). |
|
||||||
|
| P-R22 | Approval summaries render **machine-extracted payload facts unconditionally** (recipient, amount, target host — no tunable similarity threshold); agent prose is secondary. **Canary approvals** are gateway-generated, short-circuited at the gateway (never executable), immediately disclosed after decision, timing seeded outside agent-readable stores; the gated criterion is machine-flag correctness — human catch rate is reported with binomial CI, non-blocking. |
|
||||||
|
| P-R23 | Every durable table introduced by P declares a **retention class**, linted in CI; pruning jobs are health-floor registered. Minimal mandatory audit envelope: `ts, workspace, actor, correlation_id, stack_version, schema_version` — payloads are typed free-form with pinned, checked-in queries (debate §3.2 disposition). |
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. AC-NS-10 end-to-end: a `requires_approval` action executes only post-approve; deny/timeout paths verified unchanged + audited.
|
||||||
|
2. Approval round-trip from a phone Matrix client (Element) in under 3 taps — **and** the same round-trip via `mosaic approvals` CLI with the homeserver stopped.
|
||||||
|
3. With Hermes stopped, permission flow fully served by Mosaic (feeds AC-NS-11).
|
||||||
|
4. Crash-consistency: kill the gateway at each named barrier (`before_persist`, `after_approve_before_execute`, `after_execute_before_ack`); zero double-executions, zero lost approvals across the suite.
|
||||||
|
5. All four principal-resolution fixtures (P-R16) deny + audit; policy-change race (P-R17) fails `policy_changed`, never executes under stale grants.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Automated quality gates (coordinator/CI approvals) — different system, already exists.
|
||||||
|
- Fine-grained LLM output moderation — out of scope; this governs _actions_.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- ASSUMPTION: the durable queue rides the native Postgres storage service (same substrate as the backlog), not a new datastore.
|
||||||
|
- ASSUMPTION: routine delivery operations already hard-gated as no-confirmation (push/merge per Mosaic contract) are NOT routed through the relay — the relay is for `requires_approval` capabilities only, so it does not reintroduce routine confirmation prompts.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# PRD — webUI Fleet Control · Workstream W (realizes F6)
|
||||||
|
|
||||||
|
> **Status:** DRAFT for ratification · **Goals:** W1–W3 · **Upstream anchor:** `PRD-fleet-suite.md` Phase F6 ("webUI hooks — stable JSON contract + terminate/attach(butt-in) surface")
|
||||||
|
> Confirmed gap: zero xterm/pty/tmux code in `apps/web` on either the old snapshot or `origin/main`.
|
||||||
|
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
The user can pop in on **any** agentic tmux session from the web, and get a full top-down view of the system — fleet roster, health, work in flight, spend — without touching a terminal. This is the product surface for "user has ability to pop in on any agent session; full top-down view available."
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Attach service (W1)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| W-R1 | A gateway service exposes per-agent session streams over WebSocket: **watch** (read-only pane view, cannot type) and **butt-in** (interactive takeover), mirroring the existing CLI verbs `mosaic agent watch/attach`. |
|
||||||
|
| W-R2 | Authz is workspace-scoped through the product auth stack (BetterAuth/Authentik); watch and butt-in are separate grants; butt-in may be `requires_approval` per workspace policy (workstream P). |
|
||||||
|
| W-R3 | Every attach (watch or butt-in) is audited: who, which agent, when, duration. |
|
||||||
|
| W-R4 | Butt-in visibly flags the session to the agent runtime and other viewers (no silent takeover). |
|
||||||
|
| W-R5 | Contract is stable JSON + streaming frames per F6's "stable JSON contract" requirement, so TUI/CLI and webUI share it. |
|
||||||
|
|
||||||
|
### Web terminal (W2)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ---- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| W-R6 | xterm.js view in `apps/web` wired to W1: session list → click → live pane; toggle watch↔butt-in per grants. |
|
||||||
|
| W-R7 | Reconnect-safe (network blips resume the stream), mobile-usable read-only view. |
|
||||||
|
|
||||||
|
### Top-down dashboard (W3)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| W-R8 | Fleet dashboard: roster with per-agent state (systemd + tmux + heartbeat join, as `fleet ps` provides), current card/task, last activity, drift/boot-enable warnings. |
|
||||||
|
| W-R9 | Work-in-flight view: backlog cards by state with depends_on DAG rendering; advisory spend per card (NS-2/NS-5). |
|
||||||
|
| W-R10 | Operator controls: PAUSE kill-switch (NS-8), per-agent terminate (killswitch service), queue pause/resume — each gated + audited; destructive controls confirm. |
|
||||||
|
| W-R11 | Existing widget framework (`AgentStatusWidget`, `OrchestratorEventsWidget`, SSE proxy routes) is the starting point, upgraded to the fleet contract rather than rebuilt. |
|
||||||
|
|
||||||
|
## Debate-accepted deltas (2026-07-09) — normative
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| W-R12 | **Butt-in is an exclusive lease** with explicit, visible takeover: at most one interactive holder per session at any instant; a second client must take the lease and both parties see the transfer. Input frames are sequenced and deduped so a reconnect never double-sends; a heartbeat/idle timeout closes both the lease and its audit span — audit spans always terminate (extends W-R1/W-R3/W-R4). |
|
||||||
|
| W-R13 | **Structured control plane:** PAUSE, terminate, restart, approve/deny, and queue operations are typed API verbs with RBAC and audit — never bytes typed into a tmux pane. Raw terminal input via butt-in is **rescue-only** and a separately-grantable permission from the control verbs. |
|
||||||
|
| W-R14 | **Break-glass doctrine (documented, not prevented):** SSH + `tmux attach` on the fleet host bypasses W1 and P **by design**; it is inventoried under P-R19, audited post-hoc from host logs, and never treated as a product path. PAUSE additionally has an **on-host file/CLI actuator** so a dead gateway can never lock the operator out of the control that fixes the gateway. |
|
||||||
|
| W-R15 | W3 ships the **trial metric panels** as workspace-scoped product views over product storage: canary machine-flag correctness, approval decision latency, card priority distribution, wake→ack rate, agent-authored memory retrieval fraction, human interaction load, fixed-input probe stability. The X-R9 trial evidence pack cites these panels; until W3 exists, day-1 emission targets MALS (L1). Workspace isolation fixture: workspace-2 admin sees zero workspace-1 rows. |
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. From a browser (desktop + phone), the user watches a live coder-agent pane read-only, then butt-ins with the right grant, types a message, detaches; agent session continues; audit log shows both.
|
||||||
|
2. Dashboard reflects an agent crash within one heartbeat interval; PAUSE flip halts dispatch within one tick (AC-NS-5) from the UI.
|
||||||
|
3. A user without butt-in grant can watch but cannot type (enforced server-side).
|
||||||
|
4. Two clients contend for butt-in: exactly one holds the lease at any instant, the takeover is visible to both, and after a forced reconnect the input stream shows zero duplicated or interleaved frames (W-R12).
|
||||||
|
5. With the gateway stopped, the operator PAUSEs the fleet via the on-host actuator; the bypass appears in the post-hoc audit (W-R14).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Replacing tmux as the session substrate (tmux remains the transport; web is a view).
|
||||||
|
- Cross-host federation of the dashboard (rides the existing federation workstream later, per upstream note "Phase 5 rides federation").
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- ASSUMPTION: pty bridging terminates at the gateway on the fleet host (web1), not in `apps/web`; Next.js only speaks WebSocket to the gateway.
|
||||||
|
- ASSUMPTION: the jarvis-brain dashboard's node-pty/xterm work (`dashboard/server/terminal.ts`) serves as reference implementation only; code is not ported wholesale into the multi-tenant product without the authz layer above.
|
||||||
62
docs/fleet/proposals/mosaic-platform-prd/README.md
Normal file
62
docs/fleet/proposals/mosaic-platform-prd/README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Mosaic Platform PRD — Jarvis HMI + Hermes Decommission (DRAFT for ratification)
|
||||||
|
|
||||||
|
**Date:** 2026-07-09 · **Author:** proto-Jarvis session with Jason · **Status:** Decisions D1–D12 **ratified** (fixed inputs); implementing PRD structure **DRAFT** — refined 2026-07-09 post-review, then stress-tested by a 9-persona × 2-round debate panel the same day; all 24 panel findings dispositioned and folded (see `DEBATE-FINDINGS.md`; one item **OPEN — Jason**: gate superstructure, §3.1)
|
||||||
|
**Target home:** `mosaicstack/stack` → `docs/fleet/` (NORTH_STAR.yaml additions + per-phase PRDs)
|
||||||
|
|
||||||
|
> **For the homelab orchestrator:** D1–D12 below are settled constraints, not open questions — do not reopen them. What is under review is only the _implementation_ (workstreams, goals, sequencing) that realizes them.
|
||||||
|
> **Execution:** hand to the **homelab orchestrator** as orchestrated missions once ratified (D12). Land in `docs/fleet/` from `origin/main` — the `/src/mosaic-stack` clone on web1 is 5 months stale and must not be the base. The USC/web1 environment is out of scope for the trial; its cutover (workstream X applied to web1's Hermes + mos-claude) is a post-trial phase.
|
||||||
|
|
||||||
|
## Ratified decisions (Jason, 2026-07-09)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| D1 | Jarvis conversation channel is **Matrix-first** — no #jarvis Discord channel is ever created. |
|
||||||
|
| D2 | Mosaic absorbs **all four** Hermes functions before decommission: messaging bridge, Kanban/task board, permission relay, multi-platform reach. |
|
||||||
|
| D3 | Task handoff: **native Mosaic Backlog is the record; Gitea/GitHub/local-kanban attach as bidirectional sync adapters** (upholds ASM-1). |
|
||||||
|
| D4 | Jarvis executes **PA ops directly** (email, calendar, tasks, knowledge, tickets, research); all code/infra/fleet work is delegated to Mos via the backlog. |
|
||||||
|
| D5 | Mosaic Stack is a **product from day one** — multi-user, Authentik tenancy, per-workspace isolation. |
|
||||||
|
| D6 | Multi-platform reach via **Matrix + mautrix bridges** (telegram/signal/whatsapp/slack/discord); agents only ever speak Matrix. |
|
||||||
|
| D7 | webUI builds on the existing `mosaicstack/stack` monorepo (`apps/web`), realizing the already-scoped F6 phase. |
|
||||||
|
| D8 | **PRD first, then Mos runs it** as orchestrated missions. |
|
||||||
|
| D9 | jarvis-brain flat-file data **migrates into the product as tenant #1** (workspace = Jason); brain.py/flat files retire after cutover. |
|
||||||
|
| D10 | PRD form: **extend NORTH_STAR.yaml + per-phase docs in docs/fleet/** (NS-1 compliant). |
|
||||||
|
| D11 | Jarvis runs **Opus**; Fable stays exclusive to Mos per the standing cost directive. Model tier is a persona/profile field. |
|
||||||
|
| D12 | **Trial in the homelab** (the proper mosaic-fleet deployment, built by the homelab agents from `origin/main`), NOT at USC. The USC/web1 environment runs the primitive-era implementation (`mos-claude.service`, Hermes, jarvis-brain boards) and adopts only after the homelab trial validates. Jason relays this PRD to the homelab agent for implementation. |
|
||||||
|
|
||||||
|
## Artifacts in this draft
|
||||||
|
|
||||||
|
| File | Content |
|
||||||
|
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `north-star-additions.yaml` | Proposed NORTH_STAR.yaml merge: NS-10…NS-14, workstreams J/K/W/P/Q/X + **M** (memory subsystem) + **L** (logging/telemetry), goal cards with DAG |
|
||||||
|
| `PRD-jarvis-main-agent.md` | Workstream J — the HMI main agent |
|
||||||
|
| `PRD-permission-relay.md` | Workstream P — human-in-the-loop approvals |
|
||||||
|
| `PRD-webui-fleet-control.md` | Workstream W — tmux pop-in + top-down view (realizes F6) |
|
||||||
|
| `PRD-backlog-providers.md` | Workstream Q — provider sync adapters |
|
||||||
|
| `PRD-hermes-decommission.md` | Workstream X — parity checklist, tenant-1 migration, cutover |
|
||||||
|
| `DEBATE-FINDINGS.md` | 2026-07-09 debate pass — all 24 findings dispositioned, open-disagreement judgment calls, process rules |
|
||||||
|
|
||||||
|
Workstreams **M** (M1 enhanced memory subsystem) and **L** (L1 restore MALS · L2 Mosaic-native log ingestion · L3 anonymous agentic telemetry, NS-14) carry no standalone PRD doc: M1's scope is defined by its consumers (X-R7 retrieval eval, J memory rules) and L's by the MALS lineage (`~/src/mals`, infrastructure #135); both live as goal cards in the YAML.
|
||||||
|
|
||||||
|
Workstream K (Matrix connector + mautrix bridges) intentionally has no new PRD doc: it extends the existing `docs/fleet/f4-matrix-connector.md`; its deltas are captured as K-goals in the YAML additions and referenced from the J/P/X PRDs.
|
||||||
|
|
||||||
|
## Relationship to existing upstream work
|
||||||
|
|
||||||
|
- Fleet CLI, persona library, system-type profiles (H1–H4), supervisor/dispatch (B), native backlog (A): **already exist or in flight upstream — not re-specified here.**
|
||||||
|
- `f4-matrix-connector.md`: K1 = its Phase 2 implementation (verified present on `origin/main`; Phase 1 already ships the **tmux-default connector** that serves the P1 CLI channel per J-R14a). K2 (mautrix bridges) is additive infra.
|
||||||
|
- F6 (webUI hooks) in `PRD-fleet-suite.md`: realized by workstream W (verified present on `origin/main`).
|
||||||
|
- `docs/3-architecture/guard-rails-capability-permissions.md`: original design snapshot for workstream P — **not present on `origin/main`** (lives only in the stale `/src/mosaic-stack` clone). Its essential model is therefore folded into `PRD-permission-relay.md`, which is now the **self-contained authoritative spec** for P; the old path is cited as historical origin only.
|
||||||
|
|
||||||
|
## Gate Zero — pre-ratification checklist (debate P0 #3/#4, §3.7)
|
||||||
|
|
||||||
|
Ratification does not proceed on presumption. Before D-level sign-off, run and record:
|
||||||
|
|
||||||
|
1. **Upstream-artifact audit.** Every artifact this PRD load-bears on is verified `present @ <SHA>` on `origin/main` or marked **MISSING → goal card**. Presumed-missing rows already carded by this draft: **M1** (enhanced memory subsystem — nothing on main provides vector DB + memory service today), **J6** (event/wake router), **K3** (Matrix push pipeline + homeserver ops). If an audit finds one of these actually exists, retire the card and pin the SHA; if it finds _another_ gap, card it — no silent presumption in either direction.
|
||||||
|
2. **Hermes traffic audit** (§3.7): per-platform bridge traffic + per-MCP-tool call counts over a trailing window. Platforms with live traffic become the must-have K2 subset gating X3; zero-traffic platforms become post-trial nice-to-haves. Feeds the X-R1 parity checklist and its low-n drill list.
|
||||||
|
3. **Sandbox dispatch-test** of the DAG: load `north-star-additions.yaml` into a sandbox backlog and verify the dispatcher's actual claim order respects every `depends_on` edge (phases are documentation; edges are law).
|
||||||
|
|
||||||
|
## Process rules adopted from the debate pass (normative for this PRD's execution)
|
||||||
|
|
||||||
|
- **Conflict register:** a discovered contradiction between spec documents **blocks the affected requirement** (not the whole mission) until a register entry records the resolution. "Alert, don't auto-resolve" is promoted from data conflicts to spec conflicts. A CI lint greps for register references in amended docs.
|
||||||
|
- **DoD line on every goal card:** each card states its definition-of-done additions — runbook updated, health-floor alert registered, AGENTS.md touched — so operational debt can't silently accrue card-by-card.
|
||||||
|
- **Silent-roster rule:** in any panel/review round, a member's silence records their open findings as **open items, never consensus** (instance: Codex's unanswered principal-resolution and policy-evaluation-time findings were folded as P-R16/P-R17, explicitly not consensus-resolved).
|
||||||
|
- **Immutable spec + typed amendments** (J-R20) applies to these PRD docs themselves post-ratification: changes arrive as amendments with a revision counter, and sign-offs pin the revision.
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# Proposed additions to docs/fleet/NORTH_STAR.yaml — DRAFT (Jason ratification pending, 2026-07-09)
|
||||||
|
#
|
||||||
|
# Merge these entries into the existing NORTH_STAR.yaml sections, then regenerate
|
||||||
|
# NORTH_STAR.md via renderNorthStarMarkdown (packages/mosaic/src/commands/fleet.ts).
|
||||||
|
# Ids chosen to avoid collision with existing workstreams A–H and goals.
|
||||||
|
|
||||||
|
standing_objectives:
|
||||||
|
- id: NS-10
|
||||||
|
text: >-
|
||||||
|
Every Mosaic workspace runs exactly one always-on HMI main agent (default
|
||||||
|
alias "Jarvis", unit mosaic-agent@main) that owns all human conversation
|
||||||
|
and user-level personal-assistant work (ideas, schedule, email, tasks,
|
||||||
|
knowledge) and delegates engineering/research/ops missions to the
|
||||||
|
orchestrator as Mosaic Backlog cards. It is a Level-0 orchestrator:
|
||||||
|
it accomplishes work through delegation and subagents, never by executing
|
||||||
|
coding/infra tasks itself, and it runs on model capacity separate from the
|
||||||
|
orchestrator so its conversational latency is isolated from fleet load. The
|
||||||
|
main agent never executes fleet work itself and never interrupts the
|
||||||
|
orchestrator for status. Exactly-one-per-workspace is enforced by a
|
||||||
|
workspace lease (J-R16), not by convention.
|
||||||
|
- id: NS-11
|
||||||
|
text: >-
|
||||||
|
Irreversible or externally-visible agent actions pass a human-in-the-loop
|
||||||
|
permission relay (approve/deny from chat or webUI) governed by
|
||||||
|
per-capability guard rails; prepare freely, execute with approval.
|
||||||
|
- id: NS-12
|
||||||
|
text: >-
|
||||||
|
The Mosaic Backlog remains the sole backlog of record; external providers
|
||||||
|
(Gitea, GitHub, local kanban, …) attach as bidirectional sync adapters,
|
||||||
|
never as the record.
|
||||||
|
- id: NS-13
|
||||||
|
text: >-
|
||||||
|
Hermes is fully decommissioned once Mosaic reaches verified parity on
|
||||||
|
transport (Matrix connector), task board (native backlog + webUI),
|
||||||
|
permission relay, and multi-platform reach (mautrix bridges).
|
||||||
|
- id: NS-14
|
||||||
|
text: >-
|
||||||
|
Mosaic Stack accepts inbound structured error/log reporting from every
|
||||||
|
agent and install (MALS-lineage logging service); agentic-efficiency
|
||||||
|
telemetry is optional, opt-in, and anonymous by construction — no IP or
|
||||||
|
PII is captured or derivable from the ingestion path.
|
||||||
|
|
||||||
|
success_criteria:
|
||||||
|
- id: AC-NS-8
|
||||||
|
text: >-
|
||||||
|
A user converses with the main agent in its channel while the
|
||||||
|
orchestrator is under full load; because the main agent runs on separate
|
||||||
|
model capacity (distinct credential/quota pools pinned in the J1
|
||||||
|
profile), its response latency is unaffected and the orchestrator
|
||||||
|
receives zero conversational traffic. Measured, not vibes: scripted
|
||||||
|
suite, >=30 interleaved turns, TTFT p95 <= 1.2x idle baseline with
|
||||||
|
bootstrap CI.
|
||||||
|
- id: AC-NS-9
|
||||||
|
text: >-
|
||||||
|
A mission agreed in the main-agent conversation appears as a backlog card
|
||||||
|
set with acceptance criteria, is drained by the orchestrator without
|
||||||
|
chat-level handoff, and its completion is reported back to the user by the
|
||||||
|
main agent from board/heartbeat state alone.
|
||||||
|
- id: AC-NS-10
|
||||||
|
text: >-
|
||||||
|
An action listed as requires_approval executes only after an explicit
|
||||||
|
human approve from Matrix or webUI; deny and timeout paths leave the
|
||||||
|
system unchanged and audited.
|
||||||
|
- id: AC-NS-11
|
||||||
|
text: >-
|
||||||
|
With Hermes stopped, no fleet or main-agent capability regresses
|
||||||
|
(transport, board, approvals, multi-platform reach all served by Mosaic).
|
||||||
|
|
||||||
|
workstreams:
|
||||||
|
- id: J
|
||||||
|
title: HMI main agent ("Jarvis") — persona, PA toolchain, delegation contract
|
||||||
|
- id: K
|
||||||
|
title: Connectors & multi-platform reach — F4 Matrix implementation + mautrix bridges
|
||||||
|
- id: W
|
||||||
|
title: webUI fleet control — tmux pop-in, top-down view (realizes F6)
|
||||||
|
- id: P
|
||||||
|
title: Permission relay — capability guard rails + human approval queue
|
||||||
|
- id: Q
|
||||||
|
title: Backlog provider sync adapters — Gitea/GitHub/local kanban
|
||||||
|
- id: X
|
||||||
|
title: Hermes decommission & tenant-1 migration
|
||||||
|
- id: M
|
||||||
|
title: Enhanced memory subsystem — vector DB + memory service + provenance/tombstones (Gate Zero MISSING artifact)
|
||||||
|
- id: L
|
||||||
|
title: Logging & telemetry — MALS-lineage inbound error reporting + optional anonymous efficiency telemetry
|
||||||
|
|
||||||
|
goals:
|
||||||
|
# J — HMI main agent
|
||||||
|
- id: J1
|
||||||
|
title: Main-agent persona + profile — instantiate personal-assistant system type as mosaic-agent@main (alias Jarvis), model tier a profile field (Opus default)
|
||||||
|
phase: 1
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [H2]
|
||||||
|
# J2 split (debate P0 #2): phase-1 Jarvis must not hold an external-write
|
||||||
|
# credential path before the permission relay (P2) exists — NS-11 by DAG.
|
||||||
|
- id: J2a
|
||||||
|
title: PA toolchain (workspace-internal) — tasks, events, knowledge, ideas executed directly against the product API in the user's workspace; no external credentials provisioned
|
||||||
|
phase: 1
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [J1]
|
||||||
|
- id: J2b
|
||||||
|
title: PA toolchain (external integrations) — email, external calendars, helpdesk via workspace-scoped integrations; credentials gateway-held; requires_approval routes through the relay
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [J2a, P2]
|
||||||
|
- id: J3
|
||||||
|
title: Delegation contract — main agent authors mission cards (goal, acceptance criteria, budget advisory) onto the backlog; orchestrator drains; no chat-level handoff
|
||||||
|
phase: 1
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [J1, A3a]
|
||||||
|
- id: J4
|
||||||
|
title: Passive fleet observability — main agent answers status from heartbeats, fleet ps JSON, and board state; zero orchestrator interrupts
|
||||||
|
phase: 1
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [J1, B1]
|
||||||
|
- id: J5
|
||||||
|
title: Main-agent Matrix room via OrchestratorConnector(matrix)
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [J1, K1]
|
||||||
|
- id: J6
|
||||||
|
title: Shared event/wake router — level-triggered reconciler over durable state (heartbeats, cards, approvals) with hysteretic per-condition suppression; templated provenance-tagged wake turns; per-agent polling forbidden (Gate Zero MISSING artifact; J-R13 depends on it)
|
||||||
|
phase: 1
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [J1]
|
||||||
|
|
||||||
|
# K — connectors & reach (extends f4-matrix-connector.md)
|
||||||
|
- id: K1
|
||||||
|
title: Matrix connector implementation — CS-API client factory per f4 Phase 2, self-hosted homeserver
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: []
|
||||||
|
# K2 scope (debate §3.7): a pre-ratification Hermes traffic audit narrows the
|
||||||
|
# must-have bridge subset to platforms with live traffic; only that subset
|
||||||
|
# gates X3 — remaining bridges are genuinely optional post-X3.
|
||||||
|
- id: K2
|
||||||
|
title: mautrix bridge deployment (telegram/signal/whatsapp/slack/discord) as GitOps-managed infra; agents speak only Matrix; must-have subset = platforms carrying live Hermes traffic per Gate Zero audit
|
||||||
|
phase: 3
|
||||||
|
priority: should-have
|
||||||
|
depends_on: [K1]
|
||||||
|
- id: K3
|
||||||
|
title: Push pipeline (Sygnal or equivalent) + homeserver ops — monitored delivery checks, health-floor registration, rehearsed backup/restore (Gate Zero MISSING artifact; P2 phone-approval AC depends on it)
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [K1]
|
||||||
|
|
||||||
|
# W — webUI fleet control (realizes F6)
|
||||||
|
- id: W1
|
||||||
|
title: Gateway pty/tmux attach service — read-only watch and interactive butt-in verbs, workspace-scoped authz, audit log
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: []
|
||||||
|
- id: W2
|
||||||
|
title: xterm.js session view in apps/web wired to W1 (watch + butt-in)
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [W1]
|
||||||
|
- id: W3
|
||||||
|
title: Top-down fleet dashboard — roster, heartbeats, cards in flight, advisory spend, PAUSE control
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [B1]
|
||||||
|
|
||||||
|
# P — permission relay
|
||||||
|
- id: P1
|
||||||
|
title: Capability guard-rails engine — resource:action grants, permission levels, requires_approval list per integration
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: []
|
||||||
|
- id: P2
|
||||||
|
title: Approval queue + approve/deny from the Matrix room (timeout = deny; full audit)
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [P1, K1]
|
||||||
|
- id: P3
|
||||||
|
title: Approval surface in webUI (pending queue, one-click approve/deny)
|
||||||
|
phase: 3
|
||||||
|
priority: should-have
|
||||||
|
depends_on: [P1, W3]
|
||||||
|
|
||||||
|
# Q — backlog provider sync adapters
|
||||||
|
- id: Q1
|
||||||
|
title: Provider adapter interface + Gitea adapter (bidirectional card↔issue sync; native backlog stays record)
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [A2]
|
||||||
|
- id: Q2
|
||||||
|
title: GitHub adapter
|
||||||
|
phase: 3
|
||||||
|
priority: should-have
|
||||||
|
depends_on: [Q1]
|
||||||
|
- id: Q3
|
||||||
|
title: Local kanban surface — webUI board view over the native backlog (no external provider required)
|
||||||
|
phase: 2
|
||||||
|
priority: should-have
|
||||||
|
depends_on: [A2, W3]
|
||||||
|
|
||||||
|
# X — Hermes decommission & tenant-1 migration
|
||||||
|
- id: X1
|
||||||
|
title: Hermes parity checklist + cutover plan (transport, board, approvals, reach) with rollback
|
||||||
|
phase: 3
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [K1, P2, Q1]
|
||||||
|
# jarvis-brain is the P0 (prototype) Mosaic Stack; migration targets the proper
|
||||||
|
# stack storage layer. Storage authority (debate P0 #1): Postgres is
|
||||||
|
# authoritative for ALL product entities; the flat-file backend is a derived,
|
||||||
|
# regenerated, READ-ONLY projection — never a second writable store.
|
||||||
|
- id: X2
|
||||||
|
title: 'Tenant-1 migration — P0 (jarvis-brain) into the proper Mosaic Stack: (a) PA data (projects/tasks/events/knowledge) into the Jason workspace, (b) agent memory/runbooks into the enhanced memory subsystem M1 (kept live, not frozen); jarvis-brain retires read-only only after both are verified'
|
||||||
|
phase: 3
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [J2a, P2, M1]
|
||||||
|
- id: X3
|
||||||
|
title: Hermes decommission — stop and remove Hermes services after AC-NS-11 verified; pre-stop evidence snapshot (logs/config/callback inventory + checksum) is a machine gate
|
||||||
|
phase: 4
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [X1, X2, K2]
|
||||||
|
|
||||||
|
# M — enhanced memory subsystem (Gate Zero: cited by X2(b) but not built by
|
||||||
|
# any prior workstream — this card closes that gap)
|
||||||
|
- id: M1
|
||||||
|
title: Enhanced memory subsystem — vector DB + memory service with mandatory provenance (human/agent/external), source-grounded retrieval preference, tombstones + re-embedding on source update, normative/parametric corpus split
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: []
|
||||||
|
|
||||||
|
# L — logging & telemetry (Jason 2026-07-09; NS-14). MALS (~/src/mals) is the
|
||||||
|
# P0 of this capability; restoring it is infra work tracked as
|
||||||
|
# infrastructure#135 (k3s migration landed without an Ingress).
|
||||||
|
- id: L1
|
||||||
|
title: MALS restored & exposed — k3s Ingress for mals.mosaicstack.dev, health verified, authenticated write smoke-tested (infrastructure#135); trial day-1 metric emission targets MALS until W3 panels exist
|
||||||
|
phase: 1
|
||||||
|
priority: must-have
|
||||||
|
depends_on: []
|
||||||
|
- id: L2
|
||||||
|
title: Mosaic-native log ingestion — gateway/API endpoint for structured inbound error reporting from agents and installs (MALS-compatible schema; levels, categories, trace ids), workspace-scoped keys
|
||||||
|
phase: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [L1]
|
||||||
|
- id: L3
|
||||||
|
title: Anonymous agentic-efficiency telemetry — opt-in, aggregate-only, no IP/PII captured or derivable at ingestion (NS-14); feeds W3 trend panels
|
||||||
|
phase: 3
|
||||||
|
priority: should-have
|
||||||
|
depends_on: [L2, W3]
|
||||||
|
|
||||||
|
assumptions:
|
||||||
|
- id: ASM-5
|
||||||
|
vetoable: true
|
||||||
|
text: >-
|
||||||
|
The main agent initially runs on the homelab fleet host alongside the
|
||||||
|
orchestrator under mosaic-agent@main.service; host placement is a config
|
||||||
|
field, not a code assumption. Host co-location does NOT imply shared
|
||||||
|
inference: Jarvis (Opus) and Mos (Fable) hold separate model capacity/quota
|
||||||
|
so orchestrator load cannot degrade conversational latency (AC-NS-8).
|
||||||
|
- id: ASM-6
|
||||||
|
vetoable: true
|
||||||
|
text: >-
|
||||||
|
Product multi-tenancy at MVP means self-hosted installs with multiple
|
||||||
|
workspaces per install (Authentik OIDC); per-tenant isolated FLEETS
|
||||||
|
(agents per workspace) are post-MVP.
|
||||||
|
- id: ASM-7
|
||||||
|
vetoable: true
|
||||||
|
text: >-
|
||||||
|
mautrix bridges are deployed as infrastructure (GitOps), not as Mosaic
|
||||||
|
application code; Mosaic's only conversational protocol is Matrix.
|
||||||
|
- id: ASM-8
|
||||||
|
vetoable: true
|
||||||
|
text: >-
|
||||||
|
During migration (before X3), Hermes remains running untouched; no
|
||||||
|
Hermes-dependent capability is removed until its Mosaic replacement is
|
||||||
|
verified in production.
|
||||||
|
- id: ASM-9
|
||||||
|
vetoable: true
|
||||||
|
text: >-
|
||||||
|
The trial environment is the homelab fleet deployment (D12). Environments
|
||||||
|
running the primitive-era implementation (USC/web1: mos-claude.service,
|
||||||
|
Hermes, jarvis-brain boards) are untouched during the trial; workstream X
|
||||||
|
executes there as a post-trial adoption phase, environment by
|
||||||
|
environment.
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Fleet Role Classes and Authority
|
|
||||||
|
|
||||||
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
|
|
||||||
canonical class before consulting the baseline `fleet/roles/` and operator `fleet/roles.local/`
|
|
||||||
layers. A readable role contract is required; an index entry alone is not semantic success.
|
|
||||||
|
|
||||||
## Canonicalization
|
|
||||||
|
|
||||||
Only these legacy class aliases are recognized:
|
|
||||||
|
|
||||||
| Requested class | Canonical class |
|
|
||||||
| ---------------------- | --------------- |
|
|
||||||
| `implementer` | `code` |
|
|
||||||
| `reviewer` | `review` |
|
|
||||||
| `operator-interaction` | `interaction` |
|
|
||||||
|
|
||||||
No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only
|
|
||||||
when an operator supplies a readable contract for that exact class. Tess and Ultron are instance
|
|
||||||
names, not classes. `agents[].alias` is display-only and cannot grant authority.
|
|
||||||
|
|
||||||
Canonicalization happens before role lookup. For example, requesting `implementer` resolves
|
|
||||||
`code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical
|
|
||||||
`roles.local/code.md` still overrides the baseline `roles/code.md` contract.
|
|
||||||
|
|
||||||
## Protected authority
|
|
||||||
|
|
||||||
Protected authority is immutable metadata derived only from canonical class. Role prose, instance
|
|
||||||
name, display alias, tool policy, runtime, and custom role files cannot grant it.
|
|
||||||
|
|
||||||
| Canonical class | Granted authority | Explicit limits |
|
|
||||||
| ----------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
||||||
| `merge-gate` | Sole approve-to-land and merge authority | No authority is inferred by similarly named custom roles or policies. |
|
|
||||||
| `validator` | May issue a validation certificate | Cannot approve-to-land or merge. |
|
|
||||||
| `orchestrator` | May orchestrate, manage topology, and issue leases | Cannot approve-to-land or merge. |
|
|
||||||
| `team-leader` | May use orchestrator-leased capacity | Cannot issue leases or mutate roster, configuration, credentials, or merge state. |
|
|
||||||
| `interaction` | Request and status surface | Cannot orchestrate, issue leases, mutate roster/configuration, or merge. |
|
|
||||||
| all other classes | No protected authority implicitly | Custom contracts do not acquire protected powers from prose. |
|
|
||||||
|
|
||||||
Roster-v2 semantic validation requires a protected class and its canonical tool policy to match. It
|
|
||||||
also rejects an unprotected class paired with a protected tool policy. The legacy tool-policy name
|
|
||||||
`operator-interaction` canonicalizes to `interaction`.
|
|
||||||
|
|
||||||
This mapping describes authority metadata only. Lease issuance, validation-certificate storage or
|
|
||||||
workflow, lifecycle reconciliation, credentials, roster mutation, and merge execution are outside
|
|
||||||
this resolver contract.
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# Fleet Roster v2 Structural Contract
|
|
||||||
|
|
||||||
**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing,
|
|
||||||
strict structural validation, normalized in-memory representation, and deterministic rendering only.
|
|
||||||
It does not authorize role resolution, lifecycle reconciliation, mutation, migration, remote
|
|
||||||
placement, connector configuration, secret references, arbitrary commands, channels, gateway
|
|
||||||
mapping, or any live-fleet change.
|
|
||||||
|
|
||||||
The executable schema is [`roster-v2.schema.json`](./roster-v2.schema.json). The compiler exports
|
|
||||||
the same schema and its test parses this file and compares it structurally with the executable contract.
|
|
||||||
|
|
||||||
## Format and canonical shape
|
|
||||||
|
|
||||||
The compiler accepts YAML or JSON. It reads only snake_case source fields and renders canonical,
|
|
||||||
snake_case YAML. Rendering sorts runtime keys and agents by stable name. Agent names, class names,
|
|
||||||
and tool-policy names are structural identifiers; whether a class or policy resolves is a later
|
|
||||||
shared-resolver concern.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: 2
|
|
||||||
generation: 1
|
|
||||||
transport: tmux
|
|
||||||
tmux:
|
|
||||||
socket_name: mosaic-fleet
|
|
||||||
holder_session: _holder
|
|
||||||
defaults:
|
|
||||||
working_directory: ~/src
|
|
||||||
runtime: pi
|
|
||||||
runtimes:
|
|
||||||
pi:
|
|
||||||
reset_command: /new
|
|
||||||
agents:
|
|
||||||
- name: coder0
|
|
||||||
alias: Coder 0
|
|
||||||
class: code
|
|
||||||
runtime: pi
|
|
||||||
provider: openai
|
|
||||||
model: gpt-5.6-sol
|
|
||||||
reasoning: high
|
|
||||||
tool_policy: code
|
|
||||||
working_directory: ~/src
|
|
||||||
persistent_persona: false
|
|
||||||
reset_between_tasks: true
|
|
||||||
lifecycle:
|
|
||||||
enabled: true
|
|
||||||
desired_state: stopped
|
|
||||||
launch:
|
|
||||||
yolo: true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Root fields
|
|
||||||
|
|
||||||
| Field | Required | Constraint | Meaning |
|
|
||||||
| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `version` | yes | integer constant `2` | Identifies this contract. Version `1` is explicitly rejected by this compiler and remains on the existing v1 path until M4 migration. |
|
|
||||||
| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. |
|
|
||||||
| `transport` | yes | constant `tmux` | M1–M5 support local tmux only. |
|
|
||||||
| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. |
|
|
||||||
| `defaults` | yes | strict object | Default work directory and one supported local runtime. |
|
|
||||||
| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. |
|
|
||||||
| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
|
|
||||||
|
|
||||||
## Nested fields
|
|
||||||
|
|
||||||
| Path | Required | Constraint |
|
|
||||||
| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
|
|
||||||
| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity |
|
|
||||||
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
|
|
||||||
| `defaults.working_directory` | yes | non-empty string |
|
|
||||||
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
|
|
||||||
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
|
|
||||||
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
|
|
||||||
| `agents[].alias` | yes | non-empty display string |
|
|
||||||
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
|
|
||||||
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
|
|
||||||
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
|
|
||||||
| `agents[].reasoning` | yes | `low`, `medium`, or `high` |
|
|
||||||
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
|
|
||||||
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
|
|
||||||
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
|
|
||||||
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
|
||||||
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
|
||||||
|
|
||||||
## Semantic handoff
|
|
||||||
|
|
||||||
`parseRosterV2` and `normalizeRosterV2` remain synchronous and structural. After structural success,
|
|
||||||
call the asynchronous `validateRosterV2Semantics` handoff before using persona identity or authority.
|
|
||||||
That validator batches the baseline `fleet/roles/` and operator `fleet/roles.local/` scans, then
|
|
||||||
delegates every agent to the shared persona resolver.
|
|
||||||
|
|
||||||
Semantic validation:
|
|
||||||
|
|
||||||
- requires the winning role contract to be readable and non-empty; `LIBRARY.md` membership alone does
|
|
||||||
not resolve a class;
|
|
||||||
- retains `requestedClass` separately from `canonicalClass` in typed output;
|
|
||||||
- canonicalizes only `implementer` to `code`, `reviewer` to `review`, and
|
|
||||||
`operator-interaction` to `interaction`;
|
|
||||||
- canonicalizes `tool_policy` with the same exact alias table;
|
|
||||||
- rejects protected class/tool-policy mismatches in either direction, while accepting
|
|
||||||
`class: operator-interaction` with `tool_policy: operator-interaction` as canonical
|
|
||||||
`interaction`;
|
|
||||||
- derives immutable protected authority only from canonical class; and
|
|
||||||
- accepts custom baseline or `roles.local` classes without granting protected authority.
|
|
||||||
|
|
||||||
`agents[].alias` remains display-only. Tess and Ultron are instance names, never semantic classes.
|
|
||||||
Canonicalization happens before role-layer lookup, so a legacy-named override cannot redefine an
|
|
||||||
alias as separate authority. See [Role Classes and Authority](./role-classes.md) and
|
|
||||||
[Customize Fleet Roles](../how-to/customize-roles.md).
|
|
||||||
|
|
||||||
This handoff performs no filesystem, systemd, tmux, roster, credential, lease, certificate, or
|
|
||||||
lifecycle mutation.
|
|
||||||
|
|
||||||
## Fail-closed boundary
|
|
||||||
|
|
||||||
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
|
|
||||||
and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket
|
|
||||||
per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields,
|
|
||||||
and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently
|
|
||||||
ignore v1 camelCase input, version `1`, or a source that does not parse to an object.
|
|
||||||
|
|
||||||
The v2 compiler is intentionally isolated from the existing v1 loader. Existing v1 rosters and
|
|
||||||
current examples/profiles continue on their current path; FCM-M4 owns explicit inventory, preview,
|
|
||||||
migration, and rollback. FCM-M2 owns generated-file/local-override quarantine, and FCM-M3 owns
|
|
||||||
runtime lifecycle and reconciliation.
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json",
|
|
||||||
"title": "Mosaic local tmux fleet roster v2",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["version", "generation", "transport", "tmux", "defaults", "runtimes", "agents"],
|
|
||||||
"properties": {
|
|
||||||
"version": {
|
|
||||||
"const": 2
|
|
||||||
},
|
|
||||||
"generation": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 9007199254740991
|
|
||||||
},
|
|
||||||
"transport": {
|
|
||||||
"const": "tmux"
|
|
||||||
},
|
|
||||||
"tmux": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["socket_name", "holder_session"],
|
|
||||||
"properties": {
|
|
||||||
"socket_name": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
|
||||||
},
|
|
||||||
"holder_session": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"defaults": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["working_directory", "runtime"],
|
|
||||||
"properties": {
|
|
||||||
"working_directory": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"runtime": {
|
|
||||||
"enum": ["claude", "codex", "opencode", "pi"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"runtimes": {
|
|
||||||
"type": "object",
|
|
||||||
"minProperties": 1,
|
|
||||||
"propertyNames": {
|
|
||||||
"enum": ["claude", "codex", "opencode", "pi"]
|
|
||||||
},
|
|
||||||
"additionalProperties": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["reset_command"],
|
|
||||||
"properties": {
|
|
||||||
"reset_command": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"alias",
|
|
||||||
"class",
|
|
||||||
"runtime",
|
|
||||||
"provider",
|
|
||||||
"model",
|
|
||||||
"reasoning",
|
|
||||||
"tool_policy",
|
|
||||||
"working_directory",
|
|
||||||
"persistent_persona",
|
|
||||||
"reset_between_tasks",
|
|
||||||
"lifecycle",
|
|
||||||
"launch"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$"
|
|
||||||
},
|
|
||||||
"alias": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"class": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[a-z][a-z0-9-]*$"
|
|
||||||
},
|
|
||||||
"runtime": {
|
|
||||||
"enum": ["claude", "codex", "opencode", "pi"]
|
|
||||||
},
|
|
||||||
"provider": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"reasoning": {
|
|
||||||
"enum": ["low", "medium", "high"]
|
|
||||||
},
|
|
||||||
"tool_policy": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[a-z][a-z0-9-]*$"
|
|
||||||
},
|
|
||||||
"working_directory": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"persistent_persona": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"reset_between_tasks": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"lifecycle": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["enabled", "desired_state"],
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"desired_state": {
|
|
||||||
"enum": ["running", "stopped"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"launch": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["yolo"],
|
|
||||||
"properties": {
|
|
||||||
"yolo": {
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -294,68 +294,13 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
### Plugins
|
### Plugins
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | -------------------------------------------------------------------------- |
|
||||||
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
||||||
| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only |
|
|
||||||
| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata |
|
|
||||||
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
||||||
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
|
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
|
||||||
| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny |
|
|
||||||
| `DISCORD_ALLOWED_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_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
||||||
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
Session cleanup is scoped to one session identifier and only removes that session's Valkey keys and demotes that session's hot logs. Gateway startup and scheduled jobs do not perform global session cleanup; startup removes legacy repeatable `session-gc` schedules created by older deployments. The `/gc` command is intentionally disabled until a distinct global-retention job supplies explicit authorization and audit evidence. This prevents one tenant or session's cleanup from changing another's retained data.
|
|
||||||
|
|
||||||
### Observability
|
### Observability
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
# Mos Connector Lease Operations — M1
|
|
||||||
|
|
||||||
## Operational status
|
|
||||||
|
|
||||||
M1 installs the durable schema and gateway policy/adapter boundary. It does **not** activate a connector, expose a lease administration endpoint, or cut over a channel. The default gateway connector-lease policy is deny-all until a later work package supplies an authorized server-side policy and concrete adapter.
|
|
||||||
|
|
||||||
## Events to monitor
|
|
||||||
|
|
||||||
Use correlation IDs to follow `connector_lease_audit_log` events:
|
|
||||||
|
|
||||||
| Event | Meaning |
|
|
||||||
| ---------- | --------------------------------------------------------------------- |
|
|
||||||
| `acquire` | First holder inserted for an unused binding |
|
|
||||||
| `renew` | Current holder heartbeat extended the TTL |
|
|
||||||
| `takeover` | Authorized CAS replaced the holder and incremented epoch |
|
|
||||||
| `release` | Current holder explicitly relinquished authority |
|
|
||||||
| `expiry` | An expired current lease was observed |
|
|
||||||
| `reject` | Policy, CAS, expiry, scope, or fencing validation denied an operation |
|
|
||||||
|
|
||||||
Audit data is metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output.
|
|
||||||
|
|
||||||
## Incident checks
|
|
||||||
|
|
||||||
For suspected duplicate/stale connector effects:
|
|
||||||
|
|
||||||
1. Correlate the attempted operation with its `reject`, `takeover`, or `expiry` event.
|
|
||||||
2. Compare the current row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context.
|
|
||||||
3. Treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative. Do not retry it as the old holder.
|
|
||||||
4. Recovery uses the authorized takeover path with the observed expected epoch. Ordinary acquire is intentionally rejected for expired/released rows.
|
|
||||||
5. If an external effect may already have happened, preserve evidence and do not assume lease fencing provides exactly-once replay safety.
|
|
||||||
|
|
||||||
## Migration and rollback safety
|
|
||||||
|
|
||||||
Migration `0016_salty_morlocks.sql` is additive: it creates two new tables and indexes without modifying existing authorization/session tables. Before rollout, normal database backup and migration verification still apply. Rolling application code back leaves unused additive tables in place; dropping tables is not part of automated rollback because it would destroy lease/audit evidence.
|
|
||||||
|
|
||||||
## Security constraints
|
|
||||||
|
|
||||||
- Tenant comes from authenticated gateway context, never a connector request field.
|
|
||||||
- Logical agent, binding, connector, and scope identifiers use normalized constrained forms.
|
|
||||||
- Takeover requires explicit gateway policy authorization and an expected epoch.
|
|
||||||
- Default defense-in-depth TTL caps are 5 minutes for leases and 30 seconds for grants; policy may enforce stricter limits.
|
|
||||||
- Validation and rejection audit complete before adapter side effects.
|
|
||||||
- Existing authz and exact-action approval controls remain additional required gates; a valid connector lease does not bypass them.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Documentation Completion Checklist — Native Kanban/SOT Canon
|
|
||||||
|
|
||||||
**Tracking:** Mosaic Stack issue #751
|
|
||||||
**Scope:** Requirements and contract publication only; runtime implementation follows in separate slices.
|
|
||||||
|
|
||||||
## Required artifacts
|
|
||||||
|
|
||||||
- [x] Project `docs/PRD.md` exists; the workstream requirements refine its task/project-management scope.
|
|
||||||
- [x] Canonical workstream requirements published at `docs/requirements/native-kanban-sot.md`.
|
|
||||||
- [x] Mission manifest, task decomposition, frozen shared contract, and typed contract declarations included.
|
|
||||||
- [x] `docs/SITEMAP.md` updated.
|
|
||||||
- [x] Independent initial review and final GO report stored under `docs/reports/native-kanban-sot/`.
|
|
||||||
- [x] Task scratchpad stored under `docs/scratchpads/`.
|
|
||||||
- [ ] User/Admin/Developer guides — N/A for canon-only publication; required in implementation slices that change behavior or operations.
|
|
||||||
- [ ] OpenAPI and endpoint index — N/A until KBN-105 freezes implementation-ready endpoint contracts.
|
|
||||||
|
|
||||||
## Structural and root hygiene
|
|
||||||
|
|
||||||
- [x] Canonical requirements are under `docs/requirements/`.
|
|
||||||
- [x] Workstream artifacts are under `docs/native-kanban-sot/`.
|
|
||||||
- [x] Review reports are under `docs/reports/native-kanban-sot/`.
|
|
||||||
- [x] No new unscoped document was added to the `docs/` root.
|
|
||||||
- [x] Root mission/task rollups link to the workstream.
|
|
||||||
|
|
||||||
## Review gate
|
|
||||||
|
|
||||||
- [x] Author and independent reviewer are different agents.
|
|
||||||
- [x] KCR-001–016 closure was independently verified.
|
|
||||||
- [x] Ultron final gate returned GO with zero BLOCKER/HIGH findings.
|
|
||||||
- [x] Formatter, lint, typecheck, strict contract TypeScript, link, scope, and invariant publication validation passed in the current Stack toolchain.
|
|
||||||
- [ ] PR review, CI, squash merge, and issue closure remain required before publication completion.
|
|
||||||
|
|
||||||
## Publishing
|
|
||||||
|
|
||||||
- [x] Canonical source remains in-repository.
|
|
||||||
- [x] No external publishing platform is required for this internal architecture contract.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Native Kanban/SOT Canon
|
|
||||||
|
|
||||||
**Status:** KCR-001–016 independently cleared; canonical publication is in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
|
||||||
**Date:** 2026-07-14
|
|
||||||
**Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied.
|
|
||||||
|
|
||||||
## Artifacts
|
|
||||||
|
|
||||||
| Artifact | Purpose |
|
|
||||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0–P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria |
|
|
||||||
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership |
|
|
||||||
| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0–P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization |
|
|
||||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
|
||||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
|
||||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
|
||||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
|
||||||
| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults |
|
|
||||||
| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations |
|
|
||||||
| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals |
|
|
||||||
| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001–016 findings that blocked the first draft |
|
|
||||||
| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
|
|
||||||
| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
|
|
||||||
|
|
||||||
## Recommended USC lane partition
|
|
||||||
|
|
||||||
| Lane | Natural seam | Exclusive ownership |
|
|
||||||
| ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **coder2** | Schema + migrations + recovery slice | Unified Drizzle schema, migration SQL/meta/journal/tests, then recovery parser/mechanism/runbook files |
|
|
||||||
| **coder3** | Domain + Gateway + MCP server | Workspace-safe repositories, DTOs/controllers/services, exact `apps/gateway/src/mcp/**` files, health proof, proposals, Coordinator persistence adapter |
|
|
||||||
| **coder4** | Pure Coordinator + tooling | `packages/coord` mechanical engine, CLI/MCP consumers, generated projection, one-way importer and cutover tooling; lane-serialized internally |
|
|
||||||
| **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI |
|
|
||||||
| **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority |
|
|
||||||
|
|
||||||
The safe order is KBN-010 → KBN-100 → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files.
|
|
||||||
|
|
||||||
## Recovery defaults
|
|
||||||
|
|
||||||
| Tier | RPO / RTO | WAL / PITR | Base backup | Restore / break-glass | Off-cluster |
|
|
||||||
| -------------- | ------------ | ------------------- | ----------- | ----------------------- | ----------------------------------------------- |
|
|
||||||
| Lite | 24h / 24h | disabled / disabled | daily | quarterly / annual | encrypted separate target |
|
|
||||||
| Standard | 1h / 8h | q15m / 14d | daily | quarterly / semiannual | encrypted separate object storage |
|
|
||||||
| High-assurance | **15m / 4h** | **q5m / 35d** | **daily** | **monthly / quarterly** | **encrypted base+WAL, separate failure domain** |
|
|
||||||
|
|
||||||
These knobs affect recovery posture only. PostgreSQL remains the sole writable SOT in every tier. Fail-closed writes, generated-file non-authority, attributable post-recovery proposals, non-LLM Coordinator limits, and Certifier final-gate/no-merge authority are fixed for every tier.
|
|
||||||
|
|
||||||
## Non-blocking implementation sub-decisions for Mos
|
|
||||||
|
|
||||||
The source plan and ratified seven decisions resolve all build-blocking product choices. The following implementation-local selections remain for the owning slices/Mos and must not weaken v1:
|
|
||||||
|
|
||||||
1. Exact PostgreSQL write-health probe SQL and bounded proof lifetime; authority and failures are frozen.
|
|
||||||
2. Dependency-cycle serialization mechanism (recursive CTE plus transaction/advisory lock or equivalent); required behavior is frozen.
|
|
||||||
3. Whether RLS lands in the first migration or immediately after the tested session-context pattern; workspace constraints/repository authorization are required from migration one.
|
|
||||||
4. Concrete off-cluster backup provider/bucket and selected production recovery tier; High-assurance minima are frozen if selected.
|
|
||||||
5. Cutover reconciliation thresholds and stabilization duration, to be owner-approved before P3 execution.
|
|
||||||
|
|
||||||
None authorizes a second writer, dual sync, LLM scheduling, Coordinator gate waiver/merge, or Certifier merge authority.
|
|
||||||
|
|
||||||
## Publication validation evidence
|
|
||||||
|
|
||||||
- Concrete TypeScript contracts are formatted with repository Prettier.
|
|
||||||
- All four contracts pass strict TypeScript no-emit checking against the current Stack Drizzle toolchain.
|
|
||||||
- Contract remediation and KCR-001–016 traceability are recorded in the issue scratchpad and linked review reports.
|
|
||||||
- Independent re-review returned GO with KCR-001–016 closed; implementation remains held until canon merge and the dependency-ordered KBN prerequisites complete.
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
# KBN-010 — Threat, Authorization, and Constraint-Impact Gate
|
|
||||||
|
|
||||||
- **Issue:** [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753)
|
|
||||||
- **Gate status:** **PASS / GO**
|
|
||||||
- **Reviewed baseline:** `origin/main` at `49e8a54` (2026-07-14)
|
|
||||||
- **Frozen target:** `SHARED-CONTRACT.md` v1.0.0-rc.4 and `contracts/*.v1.ts`
|
|
||||||
- **Disposition input:** contract commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`
|
|
||||||
- **Scope:** documentation and future-test planning only; no runtime, schema, migration, API, configuration, dependency, CI, or deployment change
|
|
||||||
|
|
||||||
## 1. Decision
|
|
||||||
|
|
||||||
KBN-010 is **PASS / GO** against frozen contract rc.4. The original rc.3 finding remains historical detection evidence:
|
|
||||||
|
|
||||||
- **KBN010-SI-001 — rc.3 invalid mission composite-FK candidate key.** At rc.3, `missionsV1` declared a primary key on `id` and a unique key on `(workspace_id, project_id, id)`, but not a candidate key on `(workspace_id, id)`. Both `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` referenced exactly `(missions.workspace_id, missions.id)`. PostgreSQL requires the referenced column list of a foreign key to match a non-partial unique/primary candidate key; uniqueness of `id` alone did not satisfy that two-column reference. The rc.3 DDL was therefore invalid, and KBN-010 correctly blocked it.
|
|
||||||
|
|
||||||
Contract rc.4 resolves SI-001 by adding the non-partial `missions_workspace_id_uidx` candidate key on `(workspace_id, id)` while retaining the global `id` primary key and the project-congruent `(workspace_id, project_id, id)` key. Both polymorphic child FKs retain their exact workspace-safe ordered columns and `ON DELETE RESTRICT`; no target, tenancy, project-congruence, exactly-one-target, N-1, rollback, no-cascade, identity, approval, or fencing authority is weakened.
|
|
||||||
|
|
||||||
Independent Homelab non-author schema/security review returned **APPROVE** for the exact rc.4 commit/tree/content and found no collision with #757 connector fencing. SI-001 has no unresolved contract/schema-design impact.
|
|
||||||
|
|
||||||
This GO completes the KBN-010 analysis/review prerequisite only. It does **not** claim that runtime schema or migration DDL exists. KBN-100 remains held and may be released only after this PR squash-merges, the merged change reaches terminal-green CI on `main`, and issue #753 closes.
|
|
||||||
|
|
||||||
### 1.1 Independent rc.4 evidence identity
|
|
||||||
|
|
||||||
- **Commit:** `3f6a3387b419eb99453ee10dd25ba888faaab0b5`
|
|
||||||
- **Tree:** `7ebab8fa530a7180036928cea9527f808548aa14`
|
|
||||||
- **Stable full-index SHA-256:** `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`
|
|
||||||
- **Stable patch-id:** `058cf98026fcd1043703c866aee047c8bb144740`
|
|
||||||
- **Verdict:** Homelab independent non-author schema/security review **APPROVE**.
|
|
||||||
- **Reviewed conclusions:** the candidate key repairs both dependent FKs; tenant safety, polymorphic exactly-one-target semantics, RESTRICT/no-cascade behavior, and N-1/rollback semantics remain valid; #757 uses separate tables/indexes/FKs/identity/fence authority and has no collision.
|
|
||||||
|
|
||||||
A command-rendered patch SHA may differ when Git rendering options, headers, or command form differ. That rendering digest is non-authoritative. Canonical review identity is the Git commit object plus tree and exact file content; the stable full-index digest and stable patch-id above are corroborating identities.
|
|
||||||
|
|
||||||
## 2. Method and trust boundaries
|
|
||||||
|
|
||||||
### 2.1 Inputs inspected
|
|
||||||
|
|
||||||
- Canonical requirements: `docs/requirements/native-kanban-sot.md`.
|
|
||||||
- Workstream manifest and read-only task plan.
|
|
||||||
- Frozen health, schema, Mechanical Coordinator, and recovery contracts in full.
|
|
||||||
- Actual current-main schema, Better Auth guard/scope helpers, project/task/mission/team controllers and repositories, fleet backlog, and `TASKS.md` parser/writer.
|
|
||||||
- Issue #753 through the Mosaic provider wrapper.
|
|
||||||
|
|
||||||
### 2.2 Current-main exposure that the target must replace, not inherit
|
|
||||||
|
|
||||||
| Current-main fact | Constraint on future implementation |
|
|
||||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Teams are global; projects, missions, tasks, agents, and fleet backlog have no `workspace_id`. | KBN-100 must add the workspace boundary and KBN-110 must query by server-derived workspace in every repository operation. |
|
|
||||||
| `AuthGuard` authenticates a Better Auth user, while `scopeFromUser` falls back through optional tenant/team/org claims and finally user ID. | Kanban tenancy must derive from an authenticated **active workspace membership**, not this compatibility fallback or caller data. |
|
|
||||||
| Team list/get/member endpoints return global team data to any authenticated user. | New Kanban endpoints must use a uniform no-oracle denial and must not reuse global team lookup as authorization. |
|
|
||||||
| Project/task repositories load and mutate by bare IDs; controller checks are separate and sometimes distinguish not-found from forbidden. | Workspace predicates and authorization must be inside the authoritative transaction/repository command path. |
|
|
||||||
| Tasks can have nullable project/mission links, free-text assignee, JSON tags, no aggregate version, and no fence. | Expand/backfill/quarantine must precede NOT NULL/composite constraints; new commands cannot trust legacy fields. |
|
|
||||||
| `mission_tasks.status` is a second status writer. | Pre-expand must prohibit it as a write source and later retire it only after N-1 evidence. |
|
|
||||||
| Fleet `backlog` has global JSON dependencies and TTL claims without workspace, assignment, approval, session, or fencing. | It must be frozen and imported as non-dispatching shadow data; it cannot be adapted into the canonical lease path. |
|
|
||||||
| `packages/coord/src/tasks-file.ts` parses and mutates `TASKS.md`. | KBN-120 must replace production use with generated, read-only projection code and prove there is no import/mutation path. |
|
|
||||||
| No Kanban transaction-local health proof, semantic audit/event chain, change proposals, canonical outbox, approval binding, or fenced lease model exists. | These are new frozen invariants, not behaviors that may be inferred from current endpoints. |
|
|
||||||
|
|
||||||
## 3. Authorization matrix
|
|
||||||
|
|
||||||
The exact route/DTO freeze belongs to KBN-105. This matrix fixes the minimum authorization behavior that freeze and later implementation must preserve.
|
|
||||||
|
|
||||||
| Principal/state | Permitted authority | Required authoritative checks | Explicit denials |
|
|
||||||
| -------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Unauthenticated caller | Public health observation only, if deployment exposes it | Health DTO validation; no proof field accepted | All canonical reads/mutations; health observation never authorizes a write |
|
|
||||||
| Active workspace `owner`/`admin` user | Policy-allowed workspace administration and domain commands | Better Auth session; active membership; server-derived workspace; command-family role; expected version/idempotency | Foreign workspace, suspended workspace, revoked membership, caller workspace override |
|
|
||||||
| Active workspace `member` user | Policy-allowed project/task/proposal commands | Active membership plus project/team capability and target checks in the same transaction | Admin, approval, purge, service-only Coordinator, and unrelated project commands |
|
|
||||||
| Active workspace `auditor` user | Workspace-scoped reads and audit/evidence inspection | Active membership and read capability | Every mutation, approval, lease, token issuance, purge |
|
|
||||||
| Active workspace `service` identity | Only explicitly issued command families | Credential maps to workspace+agent+session; agent enabled; session live; role/capability allowlist; token expiry/audience; DB recheck per command | Raw DB credentials, user/admin fallback, cross-workspace scope, command families absent from token and registry |
|
|
||||||
| Enabled agent with live session | Agent commands matching its declared and policy-approved specialist role/capabilities | Exact workspace+agent+session binding, heartbeat/state, assignment target, lease, current decimal-string fence | Ended/offline/degraded session where policy disallows; disabled agent; another assignment/session/fence |
|
|
||||||
| Mechanical Coordinator engine | Pure eligibility/order/expiry decisions from immutable snapshots | Complete workspace-local snapshot and policy revision | Authentication, ID loading, SQL, proof minting, scope invention, approval, certification, merge |
|
|
||||||
| Coordinator persistence service | Service-only assignment/lease/checkpoint/recovery commands | Fresh transaction-local proof; locks; current assignment/approval/task/session/policy/fence | Public/user proof-by-value, stale approval/policy, direct completion/certification/merge |
|
|
||||||
| Reviewer/SecReview/Certifier | Attributable evidence decisions allowed by gate policy | Active authority, author differs from reviewer, mandatory SecReview classification, immutable artifacts | Self-review; missing evidence; Certifier merge/issue-close/release |
|
|
||||||
| Break-glass retention operator | Narrow, time-bounded purge procedure only | Separate break-glass authority, reason, scope, approvals, immutable pre-purge evidence, semantic audit, post-action reconciliation | Normal application role DELETE/UPDATE, bulk unscoped purge, unaudited hard delete |
|
|
||||||
| Revoked/expired/disabled identity or ended session | None beyond policy-permitted public observation | Revocation/lifecycle checked from PostgreSQL on every command | Cached token/Valkey state cannot preserve authority |
|
|
||||||
|
|
||||||
**No-oracle rule:** authentication may return 401, but once authenticated, a foreign-workspace, nonexistent, inaccessible, or wrong-project identifier must follow the one KBN-105-frozen 404/403 policy with the same response shape and no foreign metadata, timing-derived detail, or WebSocket/MCP discrepancy.
|
|
||||||
|
|
||||||
## 4. Threat matrix
|
|
||||||
|
|
||||||
Every disposition is against the frozen target, not a claim about current-main behavior.
|
|
||||||
|
|
||||||
| ID | Attacker or failure | Asset | Precondition and abuse path | Frozen preventive/detective control | Required schema/API/negative-test evidence | Future owner | Residual risk | Disposition |
|
|
||||||
| --- | --------------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
||||||
| T01 | Authenticated user supplies a foreign workspace/resource ID | Tenant confidentiality and integrity | Caller knows or guesses project/task/mission/team IDs and probes REST, MCP, WebSocket, repository, or Coordinator paths | `workspace_id` on every canonical row; composite relations; server-derived tenant; uniform no-oracle denial | Composite FK/unique DDL; every repository predicate includes workspace; N100-01/02, N110-01..05, N130-01 | KBN-100, 105, 110, 130 | Timing/volume side channels require operational review | Controlled after evidence |
|
|
||||||
| T02 | Revoked or inactive member retains an old session | Ownership and mutation authority | Authentication remains valid after workspace membership revocation | Active membership rechecked in the authoritative transaction for owners, principals, proposers, and decision actors | Active/inactive membership fixtures; N100-03, N110-06/07; no cached membership authority | KBN-100, 110 | Better Auth session may remain valid for unrelated features | Controlled after evidence |
|
|
||||||
| T03 | User joins/forges a team relation outside its workspace | Team-owned projects and tasks | Global-current-main team behavior or a stale membership is reused | Team is intra-workspace only; workspace/team composites; active workspace membership precedes team authorization | Cross-workspace team/member/owner insert and command denials; N100-04/05, N110-08 | KBN-100, 110 | Team-role policy mistakes remain possible | Controlled after evidence |
|
|
||||||
| T04 | Same-workspace IDs from a different project are combined | Planning hierarchy integrity | Valid mission/milestone/parent/current-milestone UUIDs are substituted | Project-congruent composite relations and serialized hierarchy validation | Mission/milestone/parent/current milestone mismatch and parent-cycle tests; N100-06..10, N110-09 | KBN-100, 110 | Deep hierarchy checks can be expensive | Controlled after evidence |
|
|
||||||
| T05 | Foreign or unrelated evidence/link/artifact IDs are attached | Review and audit truth | Caller has a valid same-workspace or foreign artifact UUID | Workspace-aware joins; immutable artifact digest/revision; semantic same-target validation in authoritative transaction | Mixed-workspace and same-workspace wrong-task/mission checkpoint/approval evidence tests; N100-11..14, N210-15/16 | KBN-100, 110, 210 | Same-workspace semantic validation is application-enforced | Controlled after evidence |
|
|
||||||
| T06 | Stolen, over-scoped, or replayed service token | Coordinator and task mutation authority | Service credential is accepted as admin/user or claims are trusted without DB state | Command-family least privilege; agent/session workspace binding; no raw DB credentials; enabled/live state checked per command | Auth registry fixtures prove audience/expiry/role/capability; revoked agent and ended session denials; N105-01, N110-10..13, N210-01/02 | KBN-105, 110, 210 | Credential theft until expiry/revocation check | Controlled after evidence |
|
|
||||||
| T07 | Caller forges public `healthy` or replays a stale health response | Sole-writer/fail-closed invariant | Public health body or caller field reaches mutation context | Public DTO is observation only; public DTOs reject proof/health fields; Gateway mints internal proof after live PG transaction probe | Contradictory union and forbidden-field tests; N105-02, N110-14..17 | KBN-105, 110, 140 | Health endpoint can still be used for reconnaissance | Controlled after evidence |
|
|
||||||
| T08 | Internal stale, wrong-policy, or wrong-transaction proof is reused | Transaction integrity | A branded value leaks or an adapter fails to revalidate it | Non-exported brand; transaction identity, `checkedAt <= now < validUntil`, and policy revision revalidated immediately before mutation | Wrong transaction, expiry boundary, future timestamp, policy mismatch, commit-after-expiry tests; N110-18..22 | KBN-110, 140 | In-process code can bypass TypeScript; runtime checks are mandatory | Controlled after evidence |
|
|
||||||
| T09 | DB/transport uncertainty is mislabeled as deliberate denial or conflict | Safe retry and exactly-once result | Timeout occurs before/after commit and client changes key or retries 503 | Exact 503/502/504/timeout/409 union; unknown outcome retries only with same idempotency key | Exhaustive fixture mapping and commit-before-timeout replay; N105-03, N110-23..27, N120-01/02 | KBN-105, 110, 120, 140 | External client may ignore retry rules | Controlled after evidence |
|
|
||||||
| T10 | Assignment payload forges task version, target agent/session, role, expiry, or proposer | Work routing authority | Lease service trusts command DTO rather than persisted assignment | Persisted assignment identity; exactly-one principal/proposer; exact agent/session composite; acquire accepts IDs then reloads+locks | Cross-workspace and same-workspace target substitutions, stale task version, invalid role, expired assignment; N100-15..18, N210-03..08 | KBN-100, 200, 210 | Compromised authorized proposer can make harmful proposals | Controlled by approval/audit |
|
|
||||||
| T11 | Approval proof is forged by value or borrowed from another assignment | Gate integrity | Caller submits `approved=true`, unrelated decision ID, stale policy, or self-approval | Relational approval bound to assignment; lock/reload; policy revision; author≠reviewer and mandatory SecReview | No proof-by-value DTO; wrong assignment/task/workspace/policy/actor/decision tests; N105-04, N210-09..14, N230-01 | KBN-105, 210, 230 | Colluding principals remain an organizational risk | Controlled after evidence |
|
|
||||||
| T12 | Revoked policy or expired proposal/assignment is raced against lease acquisition | Routing policy | Approval and lease transactions do not lock/revalidate current rows | Lock assignment, approval, task, target session; compare current policy and expiry inside fresh-proof transaction | Concurrent revoke/expire/acquire tests with one valid terminal result; N210-17..19 | KBN-210, 230 | Clock skew if DB time is not canonical | Controlled after evidence |
|
|
||||||
| T13 | Stale worker sends ack/heartbeat/checkpoint/review after reassignment | Canonical task and evidence state | Old process retains task/session IDs | Task-row-locked atomic monotonic bigint fence; every worker command carries exact lease/session/fence | Lower, expired, future, and other-task fences denied; old worker loses after new lease; N100-19/20, N210-20..24 | KBN-100, 210, 230 | Signed bigint exhaustion is theoretical | Controlled after evidence |
|
|
||||||
| T14 | JavaScript precision truncates a fence | Stale-worker exclusion | bigint token is serialized as number above `2^53-1` | Drizzle bigint and decimal-string wire type only | `9007199254740993` and near-`int8` boundary round trips; numeric JSON rejected; N105-05, N210-25 | KBN-105, 210 | Nonconforming external clients | Controlled after evidence |
|
|
||||||
| T15 | Checkpoint/evidence from another lease/task/session is submitted | Recovery and certification evidence | Same-workspace valid IDs are mixed | Exact lease composite binds workspace+task+assignment/session+fence; checkpoint composite binds lease+fence; evidence join plus semantic artifact-owner check | Same-workspace mismatched task/assignment/lease/session/checkpoint/artifact tests; N100-21..23, N210-26..31 | KBN-100, 210 | Artifact URI target may disappear outside DB | Controlled with digest/retention |
|
|
||||||
| T16 | Outage note or pending/rejected proposal mutates/orders work | Sole SOT and gate integrity | Importer/UI treats note/proposal as task state | Proposals are inert; only explicit accept invokes normal typed command after recovery | Row/outbox/task counts unchanged for pending/rejected; no readiness/dependency/lease effect; N110-28..31 | KBN-110, 140 | Humans may act outside Mosaic operationally | Accepted as attributable residual |
|
|
||||||
| T17 | Submission event is missing, foreign, or for another proposal | Proposal audit chain | Caller supplies an existing event UUID | Preallocated proposal ID; event-first same transaction; workspace composite FK; exact event type/aggregate/version semantic check | Missing/foreign/wrong-type/wrong-proposal event rolls back event+proposal; N100-24/25, N110-32..36 | KBN-100, 110 | Semantic checks are transaction code, not only FK | Controlled after evidence |
|
|
||||||
| T18 | Acceptance borrows an unrelated command event | Proposal and target integrity | Same-workspace event exists for another target/command/proposal | Accept locks proposal+target, executes normal command, requires workspace/target match, causation=submission event, payload proposal ID | Foreign, wrong target/type/command/causation/payload event aborts target/event/proposal atomically; N100-26, N110-37..43 | KBN-100, 110 | Event payload schema drift | Controlled by KBN-105 fixtures |
|
|
||||||
| T19 | Application role updates/deletes audit, approval evidence, checkpoint, or artifact | Nonrepudiation | Broad DB grants or parent cascade exists | INSERT/SELECT-only application roles; RESTRICT parent deletes; archive/cancel normal lifecycle | Role-level UPDATE/DELETE denied; parent delete RESTRICT; digest unchanged; N100-27..31 | KBN-100 | DB superuser can alter state | Break-glass/infra audit residual |
|
|
||||||
| T20 | Break-glass purge is used as routine deletion or erases its own evidence | Retention and incident forensics | Elevated credential available | Separate audited retention procedure, bounded scope, reason, pre/post evidence, authority separation | Normal role denied; expired/missing approval denied; purge cannot delete its authorizing audit package; N115-01, N230-02/03 | KBN-115, 230 | Privileged DBA compromise | Accepted operational residual |
|
|
||||||
| T21 | PostgreSQL unavailable or partitioned | Canonical state | Public health/Valkey remains live while transaction probe fails | Fail closed; no alternate writer/hidden queue; 503 only for proven not-applied; transport uncertainty remains unknown | Fault injection proves DB rows/outbox/files/Valkey unchanged on deliberate denial; commit-unknown replay; N110-44..48, N140-01 | KBN-110, 140, 230 | Availability loss is intentional | Accepted by Option A |
|
|
||||||
| T22 | Valkey unavailable, duplicated, stale, or partitioned | Scheduling notifications | Queue wake is treated as truth or publication fails | Valkey derived/expendable; transactional outbox in PG; idempotent publisher; recovery from PG | Commit with Valkey down leaves pending outbox; replay publishes once logically; stale wake reloads PG; N110-49, N140-02, N230-04..06 | KBN-110, 210, 230 | Duplicate at-least-once delivery | Consumers must be idempotent |
|
|
||||||
| T23 | Coordinator restarts between assignment, lease, checkpoint, or outbox steps | Durable orchestration truth | Process-local cache is treated as authority | PostgreSQL stores assignments, execution state, leases, fences, checkpoints, events, outbox; `recoverFromPostgres` | Restart at every transaction boundary reconstructs identical active/expired/pending sets without Valkey/files; N210-32..36, N230-07 | KBN-210, 230 | Recovery latency | Controlled after evidence |
|
|
||||||
| T24 | Dependency cycle or concurrent reciprocal edge | Readiness and dispatch safety | Two transactions each see an acyclic graph before inserting | Unique directed edge; no self-edge; serialized recursive cycle check; readiness evaluates all blockers | Self/duplicate/cycle and concurrent A→B/B→A tests; all predecessor property test; N100-32..35, N200-01/02 | KBN-100, 200, 230 | Very large DAG performance | Bounded operational residual |
|
|
||||||
| T25 | Parent-task cycle or project-incongruent relation | Planning hierarchy | Valid same-workspace IDs are arranged into an invalid tree | Project-congruent composites; serialized parent-cycle/orphan validation required by REQ-PLAN-001 | Self/indirect parent cycle, orphan, and cross-project mission/milestone/parent tests; N100-06..10 | KBN-100, 110 | Cycle validation is service/transaction enforced | Controlled after evidence |
|
|
||||||
| T26 | Concurrent update, duplicate retry, or idempotency payload drift | Aggregate consistency | Two clients use same version/key with different payloads | Expected-version check; semantic event and outbox in same transaction; key returns prior immutable result only for identical command | One update wins; stale gets 409; duplicate identical returns prior; payload drift rejected; N110-50..54, N140-03 | KBN-105, 110, 140 | Long-lived clients face visible conflicts | Intentional user-visible residual |
|
|
||||||
| T27 | State/event/outbox partial commit | Audit and notification consistency | Separate transactions or exception after state write | One PostgreSQL transaction for state+semantic event+outbox | Failure injected after each insert rolls all three back; success revisions align; N110-55..58 | KBN-110, 140 | Outbox publication remains asynchronous | Controlled after evidence |
|
|
||||||
| T28 | Malicious/incorrect importer injects foreign workspace data or dispatchable work | Migration integrity | Source keys collide, lineage is absent, or importer has direct DB authority | Immutable source snapshots/checksums; one-way Gateway/migration-only port; workspace-safe idempotent modes; shadow records cannot dispatch | Foreign/malformed/duplicate/partial-resume/lineage checksum and no-dispatch tests; N300-01..08 | KBN-300, 330 | Source data may be semantically ambiguous | Quarantine and owner sign-off |
|
|
||||||
| T29 | Cutover leaves legacy writer or forward/reverse sync active | Sole-writer invariant | Credentials/processes survive switch or rollback is improvised | Writer inventory, freeze, final delta, Gateway switch, credential shutdown, no dual write; rollback authority changes after first DB mutation | Process/credential inventory; concurrent-writer assertion; before/after-mutation rollback rehearsal; N320-01..06, N330-01 | KBN-320, 330, 340 | Missed external automation | Owner-gated residual |
|
|
||||||
| T30 | Generated `TASKS.md`/`mission.json` is edited or parsed into DB | Canonical state | Current-main parser/writer remains reachable or file watcher imports changes | Generated non-authoritative header/IDs/time/revision; no production importer; regenerate/overwrite only | Static import search, tamper/regeneration, read-only permission, source-revision parity; N120-03..07, N140-04 | KBN-120, 140 | Humans may mistake snapshots for live data | Header and docs mitigate |
|
|
||||||
| T31 | N-1 compatibility copies legacy ambiguity into canonical authority | Data integrity | Nullable/global/current-main fields are guessed during backfill | Nullable-first expand; deterministic mapping or quarantine; checksums; no new-only status before switch; legacy fields retained | Production-shape, ambiguous owner/assignee, status shadow, JSON/config/digest, rollback tests; N100-36..44 | KBN-100 | Quarantined records require human decision | Controlled by signed reconciliation |
|
|
||||||
| T32 | Recovery posture claims durability not provided by mechanisms | Availability and audit retention | Shape-only validation or optimistic RPO is accepted | Normative validator; WAL/PITR/RPO/storage/high-assurance constraints; mechanism and restore evidence | Unknown/impossible/weakened configuration plus actual mechanism/restore tests; N115-02..08 | KBN-115 | Backup operator or storage compromise | Separate failure domain residual |
|
|
||||||
| T33 | rc.3 frozen DDL could not create mission-scoped evidence/approval FKs | Tenant/evidence relational integrity | KBN-100 generated DDL from the rc.3 contract without an exact composite candidate key | rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` before both dependent FKs while retaining global and project-congruent keys | KBN-100 must execute N100-45..50: exact-key reconciliation, candidate-before-FKs, duplicate feasibility, empty/prod/N-1/rollback, and both-child foreign-workspace negatives | KBN-100 after PR/CI/#753 release | Runtime DDL remains unimplemented and must prove the frozen order | **Resolved by rc.4 + independent APPROVE; implementation evidence remains required** |
|
|
||||||
|
|
||||||
## 5. Constraint-impact matrix
|
|
||||||
|
|
||||||
| Impact ID | Required invariant | Frozen schema impact | API/transaction impact | Required evidence | Owner | Status |
|
|
||||||
| --------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------- |
|
|
||||||
| CI-01 | Hard workspace tenancy and no oracle | `workspace_id`, workspace-aware unique/FKs on all canonical rows | Server-derived workspace; uniform denial on all surfaces | N100-01..14; N110-01..09; N130-01 | KBN-100/105/110/130 | Resolved by frozen controls |
|
|
||||||
| CI-02 | Active user membership | Membership row plus unique `(workspace_id,user_id)`; active state retained | Recheck active membership in same authoritative transaction | N100-03; N110-06/07 | KBN-100/110 | Resolved; not FK-only |
|
|
||||||
| CI-03 | Service identity least privilege/revocation | Agent/session workspace, lifecycle, state, roles, capabilities | Token maps to exact agent/session; command-family allowlist; DB recheck; no admin/raw DB fallback | N105-01; N110-10..13; N210-01/02 | KBN-105/110/210 | Resolved at auth/API layer |
|
|
||||||
| CI-04 | Project-congruent hierarchy | Composite project/mission/milestone/parent/current-milestone relations | Lock/serialized parent-cycle and orphan validation | N100-06..10 | KBN-100/110 | Resolved; cycle behavior required |
|
|
||||||
| CI-05 | Health-proof authority | Internal branded proof has transaction/time/policy fields | Probe and revalidate on same PG transaction; no public field | N105-02/03; N110-14..27 | KBN-105/110 | Resolved by frozen controls |
|
|
||||||
| CI-06 | Assignment/approval identity | Exactly-one principal/proposer, exact agent/session assignment, relational approval | Reload+lock all IDs; compare version/target/state/expiry/policy/decision | N100-15..18; N210-03..19 | KBN-100/210 | Resolved by frozen controls |
|
|
||||||
| CI-07 | Monotonic bigint fencing | Durable bigint counter, exact lease/fence keys, one active lease | Atomic increment/RETURNING; decimal-string DTO; reject every stale worker command | N100-19..23; N210-20..31 | KBN-100/105/210 | Resolved by frozen controls |
|
|
||||||
| CI-08 | Proposal event chain | Both workspace-aware event FKs; event table created first | Exact submission/acceptance semantic checks in one transaction | N100-24..26; N110-28..43 | KBN-100/110 | Resolved; semantic checks not FK-only |
|
|
||||||
| CI-09 | Immutable audit/evidence retention | RESTRICT parents; INSERT/SELECT-only immutable tables | Archive/cancel normal flow; separately authorized purge | N100-27..31; N115-01; N230-02/03 | KBN-100/115/230 | Resolved by frozen controls |
|
|
||||||
| CI-10 | DB/Valkey/outbox/restart semantics | PG outbox and durable orchestration rows | Fail closed; same-key uncertainty retry; Valkey reloads PG; restart from PG | N110-44..49; N140-01/02; N230-04..07 | KBN-110/210/230 | Resolved by frozen controls |
|
|
||||||
| CI-11 | DAG/race/idempotency/version | Unique edge; self check; event idempotency; aggregate versions | Serialized recursive cycle check; payload binding; expected-version conflict | N100-32..35; N110-50..58; N200-01/02 | KBN-100/110/200 | Resolved by frozen controls |
|
|
||||||
| CI-12 | Import/cutover trust boundary | Lineage/artifact/event fields; shadow state cannot dispatch | One-way scoped importer, freeze, no direct DB/file authority, no dual writer | N300-01..08; N320-01..06 | KBN-300/320/330 | Resolved by frozen controls |
|
|
||||||
| CI-13 | Generated-file no-import | No canonical file schema/import contract | Projection-only package; static reachability check removes current parser from production Kanban paths | N120-03..07; N140-04 | KBN-120/140 | Resolved by frozen controls |
|
|
||||||
| CI-14 | Mission-scoped artifact and approval FKs | rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` and retains global/project-congruent keys | KBN-100 must emit the candidate before both exact RESTRICT FKs and preserve N-1/rollback order | N100-45..50: exact reconciliation, duplicate feasibility, empty/prod/N-1/rollback, and separate artifact/approval foreign-workspace negatives | KBN-100 after PR/CI/#753 release | **Resolved by rc.4 and independent APPROVE; future executable evidence required** |
|
|
||||||
|
|
||||||
## 6. Exact future negative-test catalog
|
|
||||||
|
|
||||||
These names are normative evidence identifiers for future slices. Equivalent test-file names are acceptable only if traceability retains these IDs and expected outcomes.
|
|
||||||
|
|
||||||
### KBN-100 — schema and migration
|
|
||||||
|
|
||||||
- **N100-01** reject every canonical child row whose `workspace_id` differs from its parent.
|
|
||||||
- **N100-02** reject foreign-workspace link, artifact, proposal target, dependency, assignment, lease, checkpoint, approval, and event relationships.
|
|
||||||
- **N100-03** reject an inactive/revoked member as accountable owner, proposer, decision actor, archive actor, or user principal in the authoritative command transaction.
|
|
||||||
- **N100-04** reject a team/project relation crossing workspaces.
|
|
||||||
- **N100-05** reject a team authorization path when the user lacks active membership in the team's workspace.
|
|
||||||
- **N100-06** reject task→mission project mismatch.
|
|
||||||
- **N100-07** reject task→milestone and project→current-milestone project mismatch.
|
|
||||||
- **N100-08** reject task→parent project mismatch and self-parent.
|
|
||||||
- **N100-09** reject indirect parent cycles under concurrent transactions.
|
|
||||||
- **N100-10** reject mission→milestone project mismatch/orphan.
|
|
||||||
- **N100-11** reject checkpoint artifact from another workspace.
|
|
||||||
- **N100-12** reject checkpoint artifact owned by another same-workspace task/mission unless an explicitly frozen evidence rule permits it.
|
|
||||||
- **N100-13** reject approval evidence from another workspace.
|
|
||||||
- **N100-14** reject same-workspace approval evidence unrelated to the approval target.
|
|
||||||
- **N100-15** reject zero/multiple assignment principals and zero/multiple proposers.
|
|
||||||
- **N100-16** reject target session without its exact target agent.
|
|
||||||
- **N100-17** reject assignment task/agent/session crossing workspaces.
|
|
||||||
- **N100-18** reject non-positive task version and expired assignment acquisition.
|
|
||||||
- **N100-19** concurrent lease insert permits one active lease and returns one winner.
|
|
||||||
- **N100-20** successive leases return strictly increasing bigint fences.
|
|
||||||
- **N100-21** reject checkpoint with another task, lease, or fence.
|
|
||||||
- **N100-22** reject duplicate/non-monotonic checkpoint sequence.
|
|
||||||
- **N100-23** reject evidence join for a mismatched checkpoint/task.
|
|
||||||
- **N100-24** proposal insert without exact submission event fails atomically.
|
|
||||||
- **N100-25** foreign/wrong-type/wrong-proposal submission event fails atomically.
|
|
||||||
- **N100-26** foreign/wrong-target/unrelated acceptance event fails atomically.
|
|
||||||
- **N100-27** application role cannot UPDATE/DELETE `task_events`.
|
|
||||||
- **N100-28** application role cannot UPDATE/DELETE checkpoints/artifacts/evidence joins.
|
|
||||||
- **N100-29** parent hard delete is RESTRICTed while audit/evidence children exist.
|
|
||||||
- **N100-30** archive does not alter canonical lifecycle status.
|
|
||||||
- **N100-31** purge without break-glass authority/evidence is denied.
|
|
||||||
- **N100-32** reject dependency self-edge and duplicate directed pair regardless of type.
|
|
||||||
- **N100-33** reject direct and indirect dependency cycles.
|
|
||||||
- **N100-34** concurrent reciprocal dependency inserts cannot both commit.
|
|
||||||
- **N100-35** readiness remains false until every blocking predecessor and completion condition passes.
|
|
||||||
- **N100-36** empty DB migration succeeds after the contract amendment.
|
|
||||||
- **N100-37** production-shape expand retains all legacy declarations.
|
|
||||||
- **N100-38** crash/resume backfill is idempotent and checksum-stable.
|
|
||||||
- **N100-39** ambiguous workspace/owner/assignee is quarantined, never guessed.
|
|
||||||
- **N100-40** no `ready`/`in_review` status is emitted to N-1 readers before switch.
|
|
||||||
- **N100-41** `mission_tasks.status` cannot remain a write source.
|
|
||||||
- **N100-42** tags/assignee/date/mission JSON/config/description/agent fields reconcile without loss.
|
|
||||||
- **N100-43** claimed fleet backlog rows are quarantined and imported rows cannot dispatch.
|
|
||||||
- **N100-44** pre-switch rollback works while post-first-mutation rollback requires freeze/reconciliation.
|
|
||||||
- **N100-45** reconcile both exact child FK column lists to the rc.4 `(workspace_id,id)` mission candidate while retaining the global `id` primary key and `(workspace_id,project_id,id)` key.
|
|
||||||
- **N100-46** empty-DB migration creates `missions_workspace_id_uidx` before `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk`.
|
|
||||||
- **N100-47** production-shape preflight finds no duplicate `(workspace_id,id)` groups, preserves global `id` uniqueness, and applies the candidate before both dependent FKs.
|
|
||||||
- **N100-48** N-1 startup/read/write remains unchanged; pre-switch rollback drops both dependents before the candidate and preserves the global/project-congruent keys.
|
|
||||||
- **N100-49** artifact insert using a valid mission ID paired with a foreign workspace fails before commit.
|
|
||||||
- **N100-50** approval-decision insert using a valid mission ID paired with a foreign workspace fails before commit.
|
|
||||||
|
|
||||||
### KBN-105/KBN-110/KBN-120/KBN-130/KBN-140 — API and P1
|
|
||||||
|
|
||||||
- **N105-01** every route has an explicit user/service command-family policy; user/admin tokens cannot call service-only Coordinator mutations.
|
|
||||||
- **N105-02** public DTO validation rejects `writeProof`, internal context, body `workspaceId`, and caller-asserted health.
|
|
||||||
- **N105-03** fixture exhaustiveness prevents 503, 502/504/timeout, and 409 cross-mapping.
|
|
||||||
- **N105-04** approval DTO accepts an ID and decision command only, never approval proof-by-value.
|
|
||||||
- **N105-05** all fence fields accept/emit decimal strings and reject JSON numbers.
|
|
||||||
- **N110-01** listing with a foreign `workspaceId` or foreign filter ID follows the frozen no-oracle denial and returns no rows/counts/cursors.
|
|
||||||
- **N110-02** get by foreign or nonexistent aggregate ID has the same frozen denial shape and no foreign metadata.
|
|
||||||
- **N110-03** create/update/archive with a foreign owner, parent, project, mission, milestone, tag, or target ID is denied before mutation.
|
|
||||||
- **N110-04** dependency/proposal commands with foreign target IDs are denied with unchanged state/event/outbox counts.
|
|
||||||
- **N110-05** REST, MCP, WebSocket, and internal Coordinator paths produce equivalent no-oracle behavior for the same foreign ID.
|
|
||||||
- **N110-06** a revoked/inactive owner is denied even with a still-valid Better Auth session.
|
|
||||||
- **N110-07** stale membership/team cache cannot authorize a proposer, decision actor, archive actor, or principal after revocation.
|
|
||||||
- **N110-08** a team ID from another workspace cannot authorize or own the command target.
|
|
||||||
- **N110-09** same-workspace but wrong-project mission/milestone/parent IDs are denied inside the transaction.
|
|
||||||
- **N110-10** an expired service token is denied before repository access.
|
|
||||||
- **N110-11** an audience- or workspace-mismatched service token is denied without an existence oracle.
|
|
||||||
- **N110-12** an over-scoped service token cannot call a command family absent from its role/capability allowlist.
|
|
||||||
- **N110-13** disabled agent or ended session revokes service-token command authority immediately on PostgreSQL recheck.
|
|
||||||
- **N110-14** contradictory public health state/boolean combinations fail validation.
|
|
||||||
- **N110-15** Valkey-only liveness cannot mint or substitute a PostgreSQL write proof.
|
|
||||||
- **N110-16** caller-forged public `healthy` cannot enter internal mutation context.
|
|
||||||
- **N110-17** public REST/MCP/CLI bodies containing health/proof fields are rejected.
|
|
||||||
- **N110-18** an expired internal proof produces no state/event/outbox write.
|
|
||||||
- **N110-19** a future-dated or not-yet-valid proof produces no write.
|
|
||||||
- **N110-20** a policy-revision-mismatched proof produces no write.
|
|
||||||
- **N110-21** a proof minted on another transaction/connection produces no write.
|
|
||||||
- **N110-22** a proof that expires before the final pre-mutation check produces no write.
|
|
||||||
- **N110-23** deliberate read-only/write-unavailable denial maps only to authoritative 503/not-applied/non-retryable.
|
|
||||||
- **N110-24** timeout before commit maps to transport-unknown and permits only same-key retry.
|
|
||||||
- **N110-25** timeout after commit maps to transport-unknown and same-key retry returns the committed canonical result once.
|
|
||||||
- **N110-26** expected-version mismatch maps only to 409/not-applied/non-retryable.
|
|
||||||
- **N110-27** recovery replay with a changed idempotency key cannot masquerade as the original uncertain request.
|
|
||||||
- **N110-28** pending proposal cannot alter target fields/status/rank/version.
|
|
||||||
- **N110-29** rejected proposal cannot affect readiness, dependencies, or gates.
|
|
||||||
- **N110-30** pending/rejected proposal cannot create an assignment or lease.
|
|
||||||
- **N110-31** direct proposal-row state manipulation cannot bypass normal command execution.
|
|
||||||
- **N110-32** proposal submission without a submission event rolls back fully.
|
|
||||||
- **N110-33** foreign-workspace submission event rolls back fully.
|
|
||||||
- **N110-34** wrong aggregate/event type submission event rolls back fully.
|
|
||||||
- **N110-35** same-workspace event for another proposal rolls back fully.
|
|
||||||
- **N110-36** submission event with wrong previous/new version semantics rolls back fully.
|
|
||||||
- **N110-37** foreign-workspace acceptance event rolls back proposal, target, event, and outbox.
|
|
||||||
- **N110-38** same-workspace event for another target aggregate rolls back acceptance.
|
|
||||||
- **N110-39** event from an unrelated normal command rolls back acceptance.
|
|
||||||
- **N110-40** event caused by a different submission event rolls back acceptance.
|
|
||||||
- **N110-41** event whose payload lacks or changes `changeProposalId` rolls back acceptance.
|
|
||||||
- **N110-42** event for another proposal with the same target/command rolls back acceptance.
|
|
||||||
- **N110-43** missing accepted-command event after target handling rolls back the entire transaction.
|
|
||||||
- **N110-44** read-only-degraded denial changes no DB row/outbox/file/Valkey/provider state.
|
|
||||||
- **N110-45** write-unavailable denial changes no DB row/outbox/file/Valkey/provider state.
|
|
||||||
- **N110-46** PostgreSQL disconnect cannot redirect a command to any fallback writer.
|
|
||||||
- **N110-47** commit uncertainty remains `unknown` and never becomes a fabricated 503/not-applied result.
|
|
||||||
- **N110-48** same-key replay after recovery returns one canonical result with no duplicate event/outbox row.
|
|
||||||
- **N110-49** Valkey publication failure leaves committed PG outbox pending and replayable.
|
|
||||||
- **N110-50** two same-version updates produce one winner and one visible 409 loser.
|
|
||||||
- **N110-51** identical duplicate key+payload returns the prior immutable result without another event/outbox row.
|
|
||||||
- **N110-52** same key with payload/command drift is rejected as an idempotency conflict.
|
|
||||||
- **N110-53** the same key in another workspace cannot reveal or reuse the first workspace's result.
|
|
||||||
- **N110-54** stale reconnect/update cannot silently overwrite a newer aggregate revision.
|
|
||||||
- **N110-55** failure after state write but before semantic event rolls back state.
|
|
||||||
- **N110-56** failure after semantic event but before outbox rolls back state and event.
|
|
||||||
- **N110-57** failure after outbox insert but before commit rolls back state, event, and outbox.
|
|
||||||
- **N110-58** success commits matching aggregate/event/outbox revisions and correlation/causation.
|
|
||||||
- **N120-01** CLI never retries an authoritative 503 deliberate denial.
|
|
||||||
- **N120-02** CLI retries only transport-unknown outcomes and preserves the exact idempotency key.
|
|
||||||
- **N120-03** generated projection header contains non-authoritative warning, workspace/project IDs, generated time, and source revision.
|
|
||||||
- **N120-04** projection revision and records match the API snapshot revision exactly.
|
|
||||||
- **N120-05** hand-tampering is overwritten or rejected by regeneration and never mutates PostgreSQL.
|
|
||||||
- **N120-06** static/runtime reachability finds no parser/import path from `TASKS.md`, `mission.json`, or another export.
|
|
||||||
- **N120-07** projection writer has no domain mutation/raw SQL/Valkey authority.
|
|
||||||
- **N130-01** UI foreign/no-access/not-found state follows the frozen no-oracle response and renders no stale foreign data.
|
|
||||||
- **N140-01** real-Gateway DB fault journey proves fail-closed no-fallback behavior.
|
|
||||||
- **N140-02** real-Gateway Valkey-loss journey proves pending outbox replay.
|
|
||||||
- **N140-03** real-Gateway concurrent update/retry journey proves version and idempotency semantics.
|
|
||||||
- **N140-04** generated-file tamper journey proves projection parity and no import.
|
|
||||||
|
|
||||||
### KBN-115/KBN-200/KBN-210/KBN-230 — recovery and coordination
|
|
||||||
|
|
||||||
- **N115-01** retention purge without current break-glass authority, reason, immutable evidence, or bounded scope is denied and audited.
|
|
||||||
- **N115-02** recovery posture with an unknown top-level or storage field is rejected.
|
|
||||||
- **N115-03** PITR retention without WAL archival is rejected.
|
|
||||||
- **N115-04** WAL archival with zero PITR retention is rejected.
|
|
||||||
- **N115-05** claimed RPO better than the configured backup/WAL mechanism is rejected.
|
|
||||||
- **N115-06** unencrypted, optional, or same-failure-domain storage is rejected.
|
|
||||||
- **N115-07** weakened high-assurance values are rejected.
|
|
||||||
- **N115-08** shape-only validation cannot pass without normative mechanism and restore evidence.
|
|
||||||
- **N200-01** cyclic/incomplete dependency snapshots never become eligible.
|
|
||||||
- **N200-02** identical immutable snapshot+policy+time returns identical ordering and explanation with no I/O/model import.
|
|
||||||
- **N210-01** disabled agent cannot claim, ack, heartbeat, checkpoint, or submit review.
|
|
||||||
- **N210-02** ended/offline/mismatched session cannot claim, ack, heartbeat, checkpoint, or submit review.
|
|
||||||
- **N210-03** foreign-workspace task is rejected after lock/reload without an oracle.
|
|
||||||
- **N210-04** stale task version is rejected before fence increment.
|
|
||||||
- **N210-05** assignment target agent mismatch is rejected.
|
|
||||||
- **N210-06** target session mismatch is rejected.
|
|
||||||
- **N210-07** expired assignment is rejected.
|
|
||||||
- **N210-08** assignment in rejected/released/expired/superseded/leased-invalid state is rejected.
|
|
||||||
- **N210-09** missing approval is rejected.
|
|
||||||
- **N210-10** rejected/escalated/requested approval is rejected as approval authority.
|
|
||||||
- **N210-11** stale policy-revision approval is rejected.
|
|
||||||
- **N210-12** foreign-workspace approval is rejected without an oracle.
|
|
||||||
- **N210-13** approval for another assignment is rejected.
|
|
||||||
- **N210-14** author self-approval/review is rejected when independence is required.
|
|
||||||
- **N210-15** foreign-workspace artifact evidence is rejected.
|
|
||||||
- **N210-16** same-workspace artifact unrelated to the assignment/task/gate is rejected.
|
|
||||||
- **N210-17** concurrent policy revocation versus acquire cannot produce a lease under the revoked revision.
|
|
||||||
- **N210-18** concurrent assignment expiry versus acquire cannot produce a lease after expiry.
|
|
||||||
- **N210-19** concurrent session end versus acquire cannot produce a lease for the ended session.
|
|
||||||
- **N210-20** lower fencing token is rejected without writes.
|
|
||||||
- **N210-21** token from an older lease is rejected without writes.
|
|
||||||
- **N210-22** token paired with another task is rejected without writes.
|
|
||||||
- **N210-23** token paired with another session is rejected without writes.
|
|
||||||
- **N210-24** token on an expired/revoked/released lease is rejected without writes.
|
|
||||||
- **N210-25** fences above JavaScript safe integer round-trip exactly as decimal strings.
|
|
||||||
- **N210-26** lease task does not match assignment task and is rejected.
|
|
||||||
- **N210-27** lease agent/session does not match assignment target and is rejected.
|
|
||||||
- **N210-28** checkpoint task does not match lease task and is rejected.
|
|
||||||
- **N210-29** checkpoint fence does not match exact lease fence and is rejected.
|
|
||||||
- **N210-30** checkpoint sequence duplicate/regression is rejected.
|
|
||||||
- **N210-31** checkpoint artifact does not match workspace/task/evidence semantics and is rejected.
|
|
||||||
- **N210-32** restart after assignment persistence reconstructs the pending assignment.
|
|
||||||
- **N210-33** restart after lease commit reconstructs exact active lease and fence.
|
|
||||||
- **N210-34** restart after checkpoint commit reconstructs checkpoint/recovery state.
|
|
||||||
- **N210-35** restart during expiry/retry/quarantine reconstructs durable disposition and eligibility.
|
|
||||||
- **N210-36** restart with pending outbox reconstructs publication work without Valkey/files.
|
|
||||||
- **N230-01** author=self-review and missing mandatory SecReview cannot certify or complete.
|
|
||||||
- **N230-02** normal application role cannot execute retention purge.
|
|
||||||
- **N230-03** break-glass purge cannot delete or alter its own authorization/evidence chain.
|
|
||||||
- **N230-04** Valkey down leaves canonical work in PostgreSQL/outbox.
|
|
||||||
- **N230-05** duplicate wake produces one logical effect after PostgreSQL reload/idempotency.
|
|
||||||
- **N230-06** stale wake cannot revive an expired/revoked assignment or lease.
|
|
||||||
- **N230-07** restart with no Valkey/files reconstructs leases/retry/quarantine/outbox exactly.
|
|
||||||
|
|
||||||
### KBN-300/KBN-320/KBN-330/KBN-340 — migration and cutover
|
|
||||||
|
|
||||||
- **N300-01** source record targeting another workspace is denied/quarantined without an oracle.
|
|
||||||
- **N300-02** malformed source record is rejected with attributable reject evidence.
|
|
||||||
- **N300-03** duplicate source system/key/batch replay is idempotent.
|
|
||||||
- **N300-04** source snapshot/checksum drift aborts apply/verify.
|
|
||||||
- **N300-05** partial import resumes from durable lineage without duplicating state/events.
|
|
||||||
- **N300-06** imported shadow record cannot become ready, assigned, or leased automatically.
|
|
||||||
- **N300-07** missing source key/file/checksum/batch lineage prevents apply/sign-off.
|
|
||||||
- **N300-08** importer cannot use direct DB, generated file, Valkey, or provider issue as canonical write authority.
|
|
||||||
- **N320-01** cutover without a verified write freeze fails safe.
|
|
||||||
- **N320-02** active legacy writer process or credential blocks cutover.
|
|
||||||
- **N320-03** reverse and forward synchronization cannot run concurrently.
|
|
||||||
- **N320-04** failed final delta/reconciliation blocks client switch.
|
|
||||||
- **N320-05** rollback before first canonical DB mutation may switch authority back only after freeze assertion.
|
|
||||||
- **N320-06** rollback after first canonical mutation requires freeze, DB-delta export/reconciliation, and owner decision.
|
|
||||||
- **N330-01** rehearsal cannot sign off while counts/checksums/exceptions/writer inventory differ.
|
|
||||||
- **N340-01** cutover cannot proceed without owner authorization, terminal evidence, scoped identities, and zero active legacy writers.
|
|
||||||
|
|
||||||
## 7. Requirements traceability
|
|
||||||
|
|
||||||
| Requirement | Threats/impacts | Planned evidence |
|
|
||||||
| ---------------- | ---------------------------- | --------------------------------------------------------------- |
|
|
||||||
| REQ-SOT-001 | T16, T21, T22, T27, T29, T30 | N110-28..31, N110-44..49, N110-55..58, N120-03..07, N320-01..06 |
|
|
||||||
| REQ-SOT-002 | T07, T08, T09, T21 | N105-02/03, N110-14..27, N110-44..48 |
|
|
||||||
| REQ-SOT-003 | T30 | N120-03..07, N140-04 |
|
|
||||||
| REQ-SOT-004 | T16..18 | N100-24..26, N110-28..43 |
|
|
||||||
| REQ-TEN-001 | T01..05, T15, T33 | N100-01..14, N100-45..50, N110-01..09, N210-15/16 |
|
|
||||||
| REQ-ID-001 | T02, T03, T06, T10..12 | N105-01, N110-06..13, N210-01..19 |
|
|
||||||
| REQ-PLAN-001 | T04, T25 | N100-06..10 |
|
|
||||||
| REQ-TASK-001 | T13, T26, T31 | N100-20, N100-37..42, N110-50..54 |
|
|
||||||
| REQ-TASK-002 | T16, T24 | N110-28..31, N100-35, N200-01 |
|
|
||||||
| REQ-DEP-001 | T24 | N100-32..35, N200-01 |
|
|
||||||
| REQ-ASN-001 | T10..12 | N100-15..18, N210-03..19 |
|
|
||||||
| REQ-AUD-001 | T17..20, T22, T27 | N100-24..31, N110-32..43, N110-49, N110-55..58 |
|
|
||||||
| REQ-API-001 | T01, T06..18, T26 | N105-01..05 plus KBN-110 catalog |
|
|
||||||
| REQ-UI-002/003 | T01, T15, T26 | N130-01 and real-Gateway KBN-140 journeys |
|
|
||||||
| REQ-COORD-001 | T22..24 | N200-01/02, N210-32..36 |
|
|
||||||
| REQ-COORD-002 | T10..12, T16 | N210-03..19, N110-28..31 |
|
|
||||||
| REQ-COORD-003 | T13..15, T23 | N100-19..23, N210-20..36 |
|
|
||||||
| REQ-COORD-004 | T23, T26 | N210-32..36, N230-07 |
|
|
||||||
| REQ-GATE-001/002 | T11, T19, T20 | N210-09..14, N230-01..03 |
|
|
||||||
| REQ-REC-001 | T20, T32 | N115-01..08 |
|
|
||||||
| REQ-MIG-001/002 | T28, T29, T31 | N100-37..44, N300-01..08, N320-01..06, N330-01, N340-01 |
|
|
||||||
|
|
||||||
REQ-UI-001 and REQ-UI-004 are downstream functional/accessibility requirements rather than schema-threat controls; they remain owned by KBN-130/KBN-140. Their security-relevant tenancy, conflict, and stale-reconnect portions are covered above.
|
|
||||||
|
|
||||||
## 8. Issue #753 acceptance mapping
|
|
||||||
|
|
||||||
| Issue requirement/criterion | Evidence in this document | Result |
|
|
||||||
| --------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
|
|
||||||
| Cross-workspace owners, principals, evidence, project hierarchy | T01–T05, T15, T25; CI-01–04 | Mapped |
|
|
||||||
| Active membership and service-token boundaries | Authorization matrix; T02, T03, T06; CI-02/03 | Mapped |
|
|
||||||
| Stale/forged health and transaction-local proof | T07–T09, T21; CI-05 | Mapped |
|
|
||||||
| Assignment/approval forgery and monotonic fencing | T10–T15; CI-06/07 | Mapped |
|
|
||||||
| Change-proposal abuse and event binding | T16–T18; CI-08 | Mapped |
|
|
||||||
| Immutable audit and break-glass | T19/T20; CI-09 | Mapped |
|
|
||||||
| PostgreSQL/Valkey failures | T21–T23; CI-10 | Mapped |
|
|
||||||
| Dependency/idempotency/version races | T24–T27; CI-11 | Mapped |
|
|
||||||
| Import/cutover and generated-file boundary | T28–T31; CI-12/13 | Mapped |
|
|
||||||
| Every schema/API/test impact explicit | Constraint matrix and negative-test catalog | Mapped |
|
|
||||||
| No unresolved schema impact | CI-14; rc.4 resolved-impact record | **PASS — none unresolved** |
|
|
||||||
| Independent SecReview | Homelab non-author exact commit/tree/content review | **PASS / APPROVE** |
|
|
||||||
| PR merge, terminal-green main CI, and #753 closure | Orchestrator-owned post-worker gates | Pending; KBN-100 remains held until completion |
|
|
||||||
|
|
||||||
## 9. UNRESOLVED SCHEMA IMPACTS
|
|
||||||
|
|
||||||
none
|
|
||||||
|
|
||||||
### Resolved-impact record — KBN010-SI-001
|
|
||||||
|
|
||||||
- **Historical detection:** rc.3 lacked an exact `(workspace_id,id)` candidate key for the artifact and approval-decision mission FKs. This document's original BLOCKED verdict was correct and remains preserved in §1 and T33.
|
|
||||||
- **Resolution:** rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` before both exact dependent FKs while retaining the global primary key and project-congruent key.
|
|
||||||
- **Reviewed object:** commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`.
|
|
||||||
- **Corroborating identities:** full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`; stable patch-id `058cf98026fcd1043703c866aee047c8bb144740`.
|
|
||||||
- **Independent verdict:** Homelab non-author schema/security review **APPROVE**. It confirmed PostgreSQL candidate/FK validity, unchanged tenant and polymorphic exactly-one-target safety, RESTRICT/no-cascade semantics, N-1/rollback validity, and no shared table/index/FK/identity/fence authority collision with #757.
|
|
||||||
- **Digest interpretation:** a command-rendered patch digest varied with rendering command/options and is non-authoritative. Git commit + tree + exact file content are canonical; stable full-index SHA-256 and stable patch-id corroborate that identity.
|
|
||||||
- **Residual implementation obligations:** KBN-100 must create the candidate before both dependent FKs; prove production-shape duplicate feasibility without weakening global uniqueness; pass empty/prod/N-1/rollback tests; reconcile both exact FK targets; and separately reject foreign-workspace mission references for artifacts and approval decisions (N100-45..50).
|
|
||||||
- **Implementation status:** no runtime schema, migration, API, or deployment implementation is claimed by this gate disposition.
|
|
||||||
|
|
||||||
## 10. Residual risk and handoff
|
|
||||||
|
|
||||||
- Active membership, polymorphic targets, same-task evidence semantics, parent/DAG cycle checks, token scope, and no-oracle behavior depend on authoritative transaction code and must not be treated as FK-only guarantees.
|
|
||||||
- DB superuser and break-glass compromise cannot be eliminated by application constraints; separation of duties, immutable external backup/audit evidence, drills, and monitoring remain required.
|
|
||||||
- PostgreSQL unavailability intentionally sacrifices writes for integrity. Transport-unknown outcomes remain safe only when clients preserve the exact idempotency key.
|
|
||||||
- Imported ambiguous records remain quarantined until owner sign-off; no automated mapping may convert ambiguity into authority.
|
|
||||||
- SI-001 is resolved at frozen contract/design-review level only. KBN-100 still owes N100-45..50 executable migration evidence.
|
|
||||||
|
|
||||||
**Handoff status:** KBN-010 **PASS / GO** at rc.4. KBN-100 remains held until this PR squash-merges, terminal-green CI completes on `main`, and issue #753 closes; the orchestrator owns those remaining gates.
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3
|
|
||||||
|
|
||||||
**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
|
||||||
**Date:** 2026-07-14
|
|
||||||
**Human decision owner:** Jason
|
|
||||||
**Orchestrator/publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage)
|
|
||||||
**Execution topology:** USC web1, partitioned across collision-free GPT coder2/3/4/5 lanes
|
|
||||||
**Canonical requirements:** [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md)
|
|
||||||
**Frozen integration contract:** `SHARED-CONTRACT.md` and `contracts/*.v1.ts`
|
|
||||||
|
|
||||||
## 1. Mission statement
|
|
||||||
|
|
||||||
Extend current `mosaicstack/stack` main into the sole native control plane for workspace-scoped project, mission, milestone, task, dependency, assignment, lease, approval, evidence, and audit state. First deliver a thin writable Kanban/List vertical slice; then add deterministic mechanical coordination and execute a one-way migration/cutover from jarvis-brain/Vikunja project/task stores.
|
|
||||||
|
|
||||||
Success means every user, agent, orchestrator, specialist, and UI sees and mutates the same PostgreSQL aggregate revisions through typed Gateway commands, with no writable fallback and no hidden second authority.
|
|
||||||
|
|
||||||
## 2. Scope boundaries
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
|
|
||||||
- Current Drizzle/PostgreSQL schema extension and migrations.
|
|
||||||
- Workspace tenancy and authorization from the first migration.
|
|
||||||
- Projects, missions, milestones, tasks, normalized tags, dependencies, assignments, durable execution/quarantine state, links, immutable artifacts/evidence joins, outage change proposals, events, approvals, leases, checkpoints, and transactional outbox.
|
|
||||||
- NestJS Gateway queries and explicit lifecycle commands.
|
|
||||||
- MCP/CLI agent surfaces and generated read-only projections.
|
|
||||||
- Thin writable Next.js Tasks Kanban/List, task detail, minimal Projects CRUD, filters, dependency readiness, ownership/lease separation, and audit timeline.
|
|
||||||
- Non-LLM Mechanical Coordinator eligibility, proposal, approval-policy, lease/fence, heartbeat, retry, expiry, quarantine, and restart recovery.
|
|
||||||
- Planning, Enhance, Coder, Review, SecReview, PR-Monitor, and Certifier role/gate representation.
|
|
||||||
- One-way shadow importer, reconciliation, write freeze, final delta, cutover, rollback package, and legacy read-only stabilization.
|
|
||||||
- Recovery-posture configuration and health-state/fail-closed contract.
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
|
|
||||||
- Greenfield services, Prisma runtime revival, or jarvis-brain flat files as runtime storage.
|
|
||||||
- Writable Markdown/JSON/Valkey/browser/provider fallback.
|
|
||||||
- Gitea issue/PR replacement or generic bidirectional provider sync.
|
|
||||||
- Calendar, email, GLPI cache, CRM, billing, time tracking, personal-brain migration.
|
|
||||||
- LLM scheduling or scope interpretation by the Coordinator.
|
|
||||||
- Autonomous gate waiver, certification, merge, release, deployment, or issue closure by Coordinator.
|
|
||||||
- Merge authority for Certifier.
|
|
||||||
- P4 full portfolio/mission designer and P5 fleet-scale policy unless separately released.
|
|
||||||
|
|
||||||
## 3. Fixed invariants
|
|
||||||
|
|
||||||
Every deployment MUST preserve all of the following:
|
|
||||||
|
|
||||||
1. PostgreSQL is the sole writable SOT.
|
|
||||||
2. Drizzle on current stack main is the only persistence foundation.
|
|
||||||
3. Mutations fail closed when DB write-health cannot be proven `healthy`.
|
|
||||||
4. No file, Valkey, browser, queue, provider, or human note becomes a fallback writer.
|
|
||||||
5. `TASKS.md`, `mission.json`, and every file export are generated, read-only, non-authoritative, and never import sources.
|
|
||||||
6. Human outage notes become attributable post-recovery proposals only.
|
|
||||||
7. Workspace is the hard tenant; Team is intra-workspace authorization.
|
|
||||||
8. Valkey is expendable; PostgreSQL owns state, leases, fencing, audit, and outbox.
|
|
||||||
9. Mechanical Coordinator is deterministic/non-LLM and cannot invent scope, waive gates, certify, or merge.
|
|
||||||
10. Certifier is the final independent quality gate and has no merge authority.
|
|
||||||
11. Mutations use idempotency and optimistic aggregate versions; worker commands also require a current fencing token.
|
|
||||||
12. Recovery tier changes only backup/recovery posture, never authority or gate semantics.
|
|
||||||
|
|
||||||
## 4. Configurable recovery posture
|
|
||||||
|
|
||||||
Deployments select Lite, Standard, or High-assurance defaults from [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md) and `contracts/recovery-posture.v1.ts`. Configurable fields are limited to:
|
|
||||||
|
|
||||||
- backup/base-backup cadence;
|
|
||||||
- RPO and RTO targets;
|
|
||||||
- PITR retention;
|
|
||||||
- WAL archive cadence;
|
|
||||||
- restore-test frequency;
|
|
||||||
- break-glass drill frequency;
|
|
||||||
- encrypted off-cluster storage.
|
|
||||||
|
|
||||||
High-assurance defaults are fixed reference values: RPO 15 minutes, RTO 4 hours, encrypted off-cluster WAL every 5 minutes with 35-day PITR, daily base backup, monthly restore test, and quarterly break-glass drill.
|
|
||||||
|
|
||||||
## 5. Canonical role map
|
|
||||||
|
|
||||||
```text
|
|
||||||
User
|
|
||||||
↓ objectives, constraints, ratified decisions
|
|
||||||
Interaction Layer
|
|
||||||
↓ workspace/project context; no scheduling authority
|
|
||||||
Portfolio Orchestrator
|
|
||||||
↓ approved mission, cross-project priority/capacity
|
|
||||||
Project Sub-Orchestrator
|
|
||||||
↓ decomposition, DAG, acceptance, release, routing policy, overrides
|
|
||||||
Gateway
|
|
||||||
↓ authenticated/authorized typed commands
|
|
||||||
Project/Task Domain Services
|
|
||||||
↓ transactional state + semantic event + outbox
|
|
||||||
Mechanical Coordinator
|
|
||||||
↓ deterministic eligibility/proposal/lease/fence/retry/quarantine
|
|
||||||
Specialists
|
|
||||||
Planning → Enhance → Coder → Review → conditional SecReview → remediation
|
|
||||||
↓ complete evidence bundle
|
|
||||||
Certifier
|
|
||||||
↓ final pass/reject/escalate; NO merge authority
|
|
||||||
Project Sub-Orchestrator / control plane
|
|
||||||
↓ merge authority after all gates
|
|
||||||
Post-merge validation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Authority table
|
|
||||||
|
|
||||||
| Role/layer | Owns | Explicitly cannot do |
|
|
||||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
||||||
| User | Objectives, constraints, Jason-owned decisions | Direct DB/file authority bypass |
|
|
||||||
| Interaction | Conversation and context resolution | Schedule, approve, lease, certify |
|
|
||||||
| Portfolio Orchestrator | Mission approval, cross-project priority/capacity/global holds | Implement or self-certify specialist work |
|
|
||||||
| Project Sub-Orchestrator | Task decomposition/DAG/acceptance, release to ready, routing policy, overrides, remediation, merge go-ahead | Bypass required independent gates |
|
|
||||||
| Gateway | Identity, tenancy, DTO validation, commands, state-machine enforcement | Accept file edits or client SQL as mutations |
|
|
||||||
| Domain services | Transactional business invariants, semantic events/outbox | Depend on Valkey/files for committed truth |
|
|
||||||
| Mechanical Coordinator | Eligibility, dependencies, proposal, approved routing, lease/fence, heartbeat, retry/quarantine | Invent/alter scope, waive gates, certify, merge |
|
|
||||||
| Specialists | Bounded planning/implementation/review artifacts under a task lease | Modify another lane's owned files or self-approve |
|
|
||||||
| Certifier | Final independent evidence/traceability/gate decision | Merge, close provider issue, release, waive policy |
|
|
||||||
|
|
||||||
## 6. Gate model
|
|
||||||
|
|
||||||
### Mandatory gates
|
|
||||||
|
|
||||||
1. Requirements/contract freeze before parallel implementation.
|
|
||||||
2. P0 schema/authority threat model and tenant isolation review.
|
|
||||||
3. Author and reviewer MUST be different principals/sessions.
|
|
||||||
4. Functional review validates requirements, endpoint registry, concurrency, and negative paths.
|
|
||||||
5. **Mandatory SecReview (`secrev`)** for any auth, authorization, tenant, service-token, secret, database schema/migration, data-integrity, import/cutover, audit, lease/fencing, recovery, or destructive-retirement surface.
|
|
||||||
6. Review findings enter bounded remediation owned by the implementation lane.
|
|
||||||
7. Raising reviewer re-verifies remediation.
|
|
||||||
8. Certifier performs the final independent evidence and traceability gate.
|
|
||||||
9. Merge authority remains with `mos-claude`/Project Sub-Orchestrator control plane after gates pass.
|
|
||||||
10. Post-merge CI and situational validation must be terminal green before closure.
|
|
||||||
|
|
||||||
### Gate outcomes
|
|
||||||
|
|
||||||
- **PASS:** evidence complete; next authority may proceed.
|
|
||||||
- **REJECT:** findings are explicit and route to remediation.
|
|
||||||
- **ESCALATE:** policy/owner decision required; no implicit waiver.
|
|
||||||
|
|
||||||
No role can transform a missing gate into a warning by changing status, editing a projection, or writing Valkey.
|
|
||||||
|
|
||||||
## 7. Slice ownership rules
|
|
||||||
|
|
||||||
1. USC web1 is the sole execution environment; coder2/3/4/5 are independent bounded lanes under Mos.
|
|
||||||
2. Every slice has one named file-tree owner and an explicit IN/OUT boundary in `TASKS.md`.
|
|
||||||
3. Two active slices MUST NOT edit the same source file, migration file, generated snapshot, lockfile, or API contract.
|
|
||||||
4. coder2 exclusively owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, migration journal/meta/tests, then its disjoint recovery-parser/runbook slice. All schema requests serialize through coder2.
|
|
||||||
5. Frozen `contracts/*.v1.ts` are read-only inputs during implementation. Contract changes require Mos approval, a version bump/amendment, and coordinated rebase before work resumes.
|
|
||||||
6. coder3 exclusively owns Gateway DTO/controllers/services and the enumerated `apps/gateway/src/mcp/**` server files. coder4 owns CLI/projection clients and never edits MCP server files. Web consumers use the exact KBN-105 endpoint/DTO freeze.
|
|
||||||
7. coder4 executes one lane order: CLI/projection → pure Coordinator → importer → cutover. The pure Coordinator under `packages/coord` does not load IDs or access DB, Gateway, Valkey, recovery I/O, or web files; coder3 owns the persistence/service adapter.
|
|
||||||
8. Migration/import tooling calls Gateway/migration-only approved ports and does not add a second database model.
|
|
||||||
9. Each lane commits only its owned files and reports any needed cross-slice change as a contract-change request instead of editing another lane's tree.
|
|
||||||
10. Cross-review is mandatory: no lane reviews its own changes. Recommended ring is coder2 ← coder5, coder3 ← coder2, coder4 ← coder3, coder5 ← coder4, followed by independent SecReview where triggered and Certifier final.
|
|
||||||
11. Integration-only edits are a separate serialized slice after component lanes are green; no opportunistic merge-conflict resolution may alter semantics.
|
|
||||||
|
|
||||||
## 8. Delivery phases and exit gates
|
|
||||||
|
|
||||||
### P0 — Canon and authority foundation
|
|
||||||
|
|
||||||
- Publish this canon, frozen schema/ports/health/recovery contracts, threat model, authorization matrix, exact endpoint/DTO registry, concrete current-main field-by-field migration map, and standards amendment.
|
|
||||||
- Build hold remains active until independent author≠reviewer re-review returns GO on health proof/failures, approval binding, fencing, tenant relationships, proposals, migration map, slice ordering/API freeze, recovery validation, and vocabulary alignment.
|
|
||||||
- Exit: no unresolved second writer or contract blocker, tenant boundary frozen, all seven decisions traceable, and independent re-review GO recorded.
|
|
||||||
|
|
||||||
### P1 — Thin native MVP
|
|
||||||
|
|
||||||
- Schema/migration, tenant-safe Gateway, CLI/MCP/projection, writable Kanban/List/Projects, dependencies/readiness/audit.
|
|
||||||
- Exit: same revision across web/CLI/MCP/projection; cross-workspace tests fail closed; generated files cannot mutate state.
|
|
||||||
|
|
||||||
### P2 — Mechanical coordination
|
|
||||||
|
|
||||||
- Agent/session registry, deterministic engine, approval queue, PostgreSQL leases/fencing/checkpoints/outbox, retry/quarantine, operations UI.
|
|
||||||
- Exit: one lease winner, stale tokens rejected, dependencies/approvals enforced, DB/Valkey fault semantics proven, Certifier gate has no merge authority.
|
|
||||||
|
|
||||||
### P3 — Shadow migration and cutover
|
|
||||||
|
|
||||||
- Importer, lineage, reconciliation, reviewer UI, write freeze, final delta, Gateway switch, legacy read-only, stabilization and rollback package.
|
|
||||||
- Exit: signed reconciliation, zero active legacy writers, scoped Gateway identities, imported backlog cannot dispatch accidentally.
|
|
||||||
|
|
||||||
## 9. Evidence required for mission closure
|
|
||||||
|
|
||||||
- Requirement-to-test/evidence matrix.
|
|
||||||
- Schema/migration and N-1 rolling-deploy proof.
|
|
||||||
- Cross-workspace API/repository/import/Coordinator negative tests.
|
|
||||||
- Health-state and fail-closed fault injection.
|
|
||||||
- Valkey-loss/outbox replay and Coordinator restart tests.
|
|
||||||
- Concurrent lease and stale fencing tests.
|
|
||||||
- Endpoint-registry alignment across web/CLI/MCP/Gateway.
|
|
||||||
- Accessible real-Gateway Kanban journeys.
|
|
||||||
- Generated projection tamper/no-import proof.
|
|
||||||
- One-way migration dry-run/apply/verify and field reconciliation.
|
|
||||||
- Author-independent functional review and required SecReview.
|
|
||||||
- Certifier final decision and evidence bundle.
|
|
||||||
- Merged main SHA, terminal green CI, closed linked task/issue, and post-merge situational validation under orchestrator ownership.
|
|
||||||
|
|
||||||
## 10. Change control
|
|
||||||
|
|
||||||
This manifest is derived from the ratified source plan. Any change to SOT authority, workspace tenancy, fixed statuses, Coordinator/Certifier authority, health-state semantics, schema v1, migration direction, or recovery-tier field set is a contract change. Contract changes require Jason/Mos authorization and cannot be inferred by an implementation lane.
|
|
||||||
|
|
||||||
No coder lane may start while the build hold is active. KBN-010 must complete before KBN-100; KBN-105 exact endpoint/DTO freeze must complete before any API consumer implementation.
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
# Native Kanban/SOT — Remediated Shared Contract v1
|
|
||||||
|
|
||||||
**Status:** CONTROL-PLANE SI-001 AMENDMENT AUTHORIZED; prior KCR-001–016 independent-review GO retained; rc.4 requires independent schema/SecReview before KBN-100
|
|
||||||
**Version:** 1.0.0-rc.4
|
|
||||||
**Date:** 2026-07-14
|
|
||||||
**Change authority:** Mosaic control plane/Jason only
|
|
||||||
**SI-001 amendment authority:** `web1:mosaic-100` control-plane decision under issue #753
|
|
||||||
|
|
||||||
## Amendment record
|
|
||||||
|
|
||||||
### 1.0.0-rc.4 — KBN010-SI-001
|
|
||||||
|
|
||||||
- **Choice:** add the explicitly named, non-partial unique candidate key `missions_workspace_id_uidx` on `missions(workspace_id, id)` and retain `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`.
|
|
||||||
- **Rationale:** mission `id` remains globally unique, while the composite candidate key makes the frozen tenant-safe generic mission relations valid. `artifacts` and `approval_decisions` are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would unnecessarily broaden v1 and its target semantics.
|
|
||||||
- **Exact effect:** `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` continue to reference the exact ordered columns `missions(workspace_id, id)` with RESTRICT deletion, now backed by a matching candidate key.
|
|
||||||
- **Non-effect:** no SOT, tenancy, project-congruence, proposal-audit, approval, fencing, immutability, no-cascade, API, or wire-version invariant changes. The `SuccessEnvelopeV1.contractVersion` remains `1.0.0`.
|
|
||||||
- **Historical evidence boundary:** `KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` intentionally remains the immutable rc.3 blocker verdict that detected SI-001; this rc.4 record and the #753 scratchpad append are the authorized disposition. Rewriting the gate verdict is outside this amendment's exclusive scope.
|
|
||||||
- **Gate:** this amendment resolves the DDL defect identified by KBN010-SI-001 but does not itself lift KBN-100; independent schema/SecReview remains required.
|
|
||||||
|
|
||||||
## 1. Authority
|
|
||||||
|
|
||||||
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.
|
|
||||||
- Mission `id` remains globally unique. The additional non-partial `missions_workspace_id_uidx` candidate key on `(workspace_id, id)` exists only to support the frozen workspace-safe polymorphic artifact and approval-decision mission relations; the project-congruent `(workspace_id, project_id, id)` key remains authoritative wherever `project_id` is present.
|
|
||||||
- Dependency identity is workspace+predecessor+successor independent of type.
|
|
||||||
- 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 Mission candidate-key and dependent-FK DDL order
|
|
||||||
|
|
||||||
KBN-100 migration DDL must execute the SI-001 portion in this order:
|
|
||||||
|
|
||||||
1. expand/backfill `missions.workspace_id` and `missions.project_id` while preserving the global `missions.id` primary key and the project-congruent `missions_workspace_project_id_uidx` key;
|
|
||||||
2. prove duplicate-key feasibility on the production-shape dataset: `(workspace_id, id)` has no duplicate groups and global `id` uniqueness remains intact;
|
|
||||||
3. create the non-partial unique index `missions_workspace_id_uidx` on exact ordered columns `(workspace_id, id)`;
|
|
||||||
4. only after step 3, create/alter `artifacts` and add `artifacts_workspace_mission_fk` from `(workspace_id, mission_id)` to exact `missions(workspace_id, id)` with `ON DELETE RESTRICT`;
|
|
||||||
5. only after step 3, create/alter `approval_decisions` and add `approval_decisions_workspace_mission_fk` from `(workspace_id, mission_id)` to exact `missions(workspace_id, id)` with `ON DELETE RESTRICT`;
|
|
||||||
6. validate both constraints and prove a mission ID paired with a foreign workspace is rejected for each child.
|
|
||||||
|
|
||||||
The candidate key is intentionally redundant with globally unique `missions.id`, but PostgreSQL requires a matching unique candidate key for the exact two-column FK target. It is additive and N-1-safe. Pre-switch rollback drops the two dependent FKs/tables before dropping this candidate key, preserves the global primary key and project-congruent key, and follows the existing freeze/reconciliation rule after the first canonical mutation.
|
|
||||||
|
|
||||||
### 5.3 New audit/proposal DDL order
|
|
||||||
|
|
||||||
KBN-100 migration DDL must execute in this order:
|
|
||||||
|
|
||||||
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.4 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 relational candidate keys | retain global `id` PK and project-congruent key; add non-partial `(workspace_id,id)` key before artifact/approval FKs | additive key is ignored safely by N-1 readers/writers | retain both composite keys; generic mission children use exact workspace+ID target |
|
|
||||||
| 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.5 Required migration tests
|
|
||||||
|
|
||||||
Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release.
|
|
||||||
|
|
||||||
SI-001 adds frozen future executable evidence: empty and production-shape migrations create `missions_workspace_id_uidx` before either dependent FK; duplicate-key feasibility preflight returns no `(workspace_id,id)` duplicate groups without weakening global `id` uniqueness; N-1 startup/read/write behavior is unchanged; pre-switch rollback removes dependents before the candidate key; both exact FK column lists reconcile to the candidate key; and foreign-workspace mission references fail for both artifacts and approval decisions. TDD is not applicable to this design-only amendment; KBN-100 must implement these negative migration tests before runtime schema release.
|
|
||||||
|
|
||||||
Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation.
|
|
||||||
|
|
||||||
## 6. Ownership and Coordinator split
|
|
||||||
|
|
||||||
coder2 solely owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, journal/metadata, and migration tests. No other lane generates migrations. Expand is additive; no drop/rename/narrow; constraints validate before NOT NULL; compatibility is same-DB only; contract is later.
|
|
||||||
|
|
||||||
KBN-200/coder4 owns pure `MechanicalCoordinatorDecisionEngineV1`: complete immutable snapshots in, deterministic eligibility/proposal/retry decisions out; no ID loading, SQL, Gateway, Valkey, proof, persistence, restart I/O, or LLM.
|
|
||||||
|
|
||||||
KBN-210/coder3 owns `MechanicalCoordinatorServicePortV1`: ID loading, locks, fresh proof, assignment/approval persistence, atomic fencing, lease/checkpoint/outbox, Valkey wakes, durable retry/quarantine, and `recoverFromPostgres`. Cycle: load snapshots → pure decision → persist assignment → authoritative approval/policy → acquire by IDs/locks → increment fence → lease → ack/heartbeat/checkpoint → submit to review or durable retry/quarantine. No completion/certification/merge method exists.
|
|
||||||
|
|
||||||
## 7. Exact Gateway/DTO freeze for KBN-105
|
|
||||||
|
|
||||||
### 7.1 Common wire rules
|
|
||||||
|
|
||||||
Base is `/api/v1/workspaces/:workspaceId`. Mutations require header `Idempotency-Key` (1–128 chars). Existing-aggregate mutations also require `If-Match-Version` (positive integer); create and privileged assignment-cycle requests are the only exceptions, while proposal submission carries `expectedTargetVersion` in its body. Body workspace fields are forbidden. Tenant denial follows one 404/403 policy without foreign existence detail.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface SuccessEnvelopeV1<T> {
|
|
||||||
contractVersion: '1.0.0';
|
|
||||||
data: T;
|
|
||||||
aggregateRevision: string;
|
|
||||||
correlationId: string;
|
|
||||||
}
|
|
||||||
interface ListEnvelopeV1<T> extends SuccessEnvelopeV1<T[]> {
|
|
||||||
page: { cursor: string | null; nextCursor: string | null; limit: number };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Errors are the exact health/transport/version unions in §2 plus validation/auth/not-found. Public DTOs never expose/accept internal write proof.
|
|
||||||
|
|
||||||
### 7.2 Exact route registry
|
|
||||||
|
|
||||||
| Method/path | Request body/query | Success data |
|
|
||||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
|
|
||||||
| `GET /kanban-health` | none | `KanbanHealthResponseV1` |
|
|
||||||
| `GET /projects` | `status,ownerUserId,ownerTeamId,cursor,limit` | project list |
|
|
||||||
| `POST /projects` | `name,key,description,status,priority,ownerUserId XOR ownerTeamId,metadata` | project |
|
|
||||||
| `GET /projects/:projectId` | none | project |
|
|
||||||
| `PATCH /projects/:projectId` | editable create fields + expected header | project |
|
|
||||||
| `POST /projects/:projectId/archive` | `reason` | project |
|
|
||||||
| `GET /tasks` | `projectId,missionId,milestoneId,status,priority,ownerUserId,ownerTeamId,specialistRole,tag,dueState,archived,cursor,limit` | task summary list |
|
|
||||||
| `POST /tasks` | `projectId,missionId?,milestoneId?,parentTaskId?,title,description?,acceptanceCriteria[],status,priority,rank,ownerUserId XOR ownerTeamId,specialistRole?,dueAt?,notBeforeAt?,estimateMinutes?,retryPolicy?,tagIds[],metadata` | task detail |
|
|
||||||
| `GET /tasks/:taskId` | none | task detail including readiness/dependencies/assignment/lease/events |
|
|
||||||
| `PATCH /tasks/:taskId` | editable non-transition fields | task detail |
|
|
||||||
| `POST /tasks/:taskId/transition` | `toStatus,reason?` | task detail |
|
|
||||||
| `POST /tasks/:taskId/move` | `toStatus?,beforeTaskId?,afterTaskId?` | task detail with persisted rank |
|
|
||||||
| `POST /tasks/:taskId/archive` | `reason` | task detail |
|
|
||||||
| `PUT /tasks/:taskId/tags` | `tagIds[]` | task detail |
|
|
||||||
| `POST /tasks/:taskId/dependencies` | `predecessorTaskId,type` | dependency |
|
|
||||||
| `DELETE /tasks/:taskId/dependencies/:predecessorTaskId` | no body | deleted dependency ID |
|
|
||||||
| `GET /tasks/:taskId/events` | `cursor,limit` | event list |
|
|
||||||
| `GET /tags` | `query,cursor,limit` | tag list |
|
|
||||||
| `POST /tags` | `name,color?` | tag |
|
|
||||||
| `GET /change-proposals` | `state,targetType,targetId,cursor,limit` | proposal list |
|
|
||||||
| `POST /change-proposals` | `sourceNoteDigest,targetType,targetId,expectedTargetVersion,commandType,commandPayload` | inert proposal |
|
|
||||||
| `GET /change-proposals/:proposalId` | none | proposal |
|
|
||||||
| `POST /change-proposals/:proposalId/accept` | `reason` | proposal + normal command result |
|
|
||||||
| `POST /change-proposals/:proposalId/reject` | `reason` | proposal |
|
|
||||||
| `GET /coordinator/eligibility` | `projectId?,missionId?,cursor,limit` | `EligibilityDecisionV1[]` |
|
|
||||||
| `POST /coordinator/assignment-cycles` | `limit` | assignment proposals; privileged internal |
|
|
||||||
| `POST /coordinator/assignments/:assignmentId/approve` | `decision,reason,policyRevision,artifactIds[]` | approval decision |
|
|
||||||
| `POST /coordinator/leases/acquire` | `taskId,assignmentId,approvalDecisionId,targetSessionId,leaseTtlSeconds` | lease with decimal-string fence |
|
|
||||||
| `POST /coordinator/leases/:leaseId/ack` | `taskId,sessionId,fencingToken` | lease |
|
|
||||||
| `POST /coordinator/leases/:leaseId/heartbeat` | `taskId,sessionId,fencingToken,extendSeconds` | lease |
|
|
||||||
| `POST /coordinator/leases/:leaseId/checkpoints` | `taskId,sessionId,fencingToken,sequence,resumableSummary,artifactIds[],contextUsagePercent` | checkpoint |
|
|
||||||
| `POST /coordinator/leases/:leaseId/submit-review` | `taskId,sessionId,fencingToken,artifactIds[],summary` | task in `in_review` |
|
|
||||||
|
|
||||||
All Coordinator mutations except human approval are service-identity-only. Generic task PATCH cannot perform claim/heartbeat/checkpoint/review/certification/completion shortcuts. Completion after certification uses a separately gated lifecycle command owned by the Portfolio/Sub-Orchestrator flow, not the Coordinator.
|
|
||||||
|
|
||||||
### 7.3 DTO invariants
|
|
||||||
|
|
||||||
Task summary/detail use exact schema vocabularies, owner union, `version: number`, `fencingCounter: string`, explicit `archivedAt/by/reason`, normalized tags, computed readiness, and separate assignment/lease. Assignment DTO includes one persisted ID, task/version, exact principal/agent/session, role, state, expiry, policy, proposer/reason. Lease/checkpoint DTOs serialize every fence as decimal string. Proposal DTO exposes no hidden write authority.
|
|
||||||
|
|
||||||
### 7.4 MCP ownership and mapping
|
|
||||||
|
|
||||||
coder3 exclusively owns:
|
|
||||||
|
|
||||||
- `apps/gateway/src/mcp/mcp.dto.ts`
|
|
||||||
- `mcp.controller.ts`
|
|
||||||
- `mcp.service.ts`
|
|
||||||
- `mcp.module.ts`
|
|
||||||
- `mcp.tokens.ts`
|
|
||||||
- `mcp.service.spec.ts`
|
|
||||||
|
|
||||||
MCP tools are thin maps: `mosaic_projects_{list,get,create,update,archive}`, `mosaic_tasks_{list,get,create,update,transition,move,archive,set_tags,add_dependency,remove_dependency}`, and `mosaic_change_proposals_{list,get,submit,accept,reject}` to the exact routes above. coder4 owns CLI/projection clients only and must not edit Gateway MCP files.
|
|
||||||
|
|
||||||
KBN-105 publishes route+DTO fixture digest before KBN-110/120/130. Every web/CLI/MCP call must match this registry and the generated client.
|
|
||||||
|
|
||||||
## 8. Recovery contract and bounded delivery slice
|
|
||||||
|
|
||||||
Runtime must invoke normative `validateRecoveryPostureV1`; JSON Schema alone is insufficient. It rejects unknown fields, PITR/WAL mismatch, RPO better than mechanism, unsafe storage, and weakened High-assurance. High-assurance is RPO 15m/RTO 4h, WAL ≤5m, PITR ≥35d, base ≤24h, restore test ≤30d, break-glass ≤90d, encrypted separate-failure-domain storage.
|
|
||||||
|
|
||||||
KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recovery runbook. It wires parser/refinement, override audit, mechanism assertions, restore test, and break-glass evidence. Any deployment manifest is separately enumerated and Mos-serialized. Recovery config has no SOT/gate/Coordinator authority fields.
|
|
||||||
|
|
||||||
## 9. Integration, security, and hold
|
|
||||||
|
|
||||||
Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
|
|
||||||
|
|
||||||
### 9.1 SI-001 amendment gate and #757 boundary
|
|
||||||
|
|
||||||
- KBN-100 must provide the §5.2 candidate-key ordering, duplicate-feasibility, exact-FK reconciliation, empty/prod/N-1/rollback, and two-child foreign-workspace evidence before SI-001 can be certified closed.
|
|
||||||
- All prior KCR-001–016 decisions and fixed SOT/tenant/authority, proposal-audit, approval, task-fencing, immutability, and no-cascade invariants remain unchanged.
|
|
||||||
- Read-only PR #757 cross-check: its logical-agent connector lease/CAS fencing uses separate runtime tables/contracts and `lease_epoch`; rc.4 changes only the frozen `missions` candidate key. There is no shared table, index, FK, identity, fence, or authority semantic to consume or reconcile, and #757 remains owned by its existing lane.
|
|
||||||
|
|
||||||
The build hold remains active until independent re-review reports GO for KCR-001–016 and the rc.4 SI-001 amendment. Mos alone releases waves and serializes integration roots.
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
# Native Kanban/SOT P0–P3 — Dependency-Ordered Build Slices
|
|
||||||
|
|
||||||
**Status:** CANON INDEPENDENTLY APPROVED; PUBLICATION IN PROGRESS
|
|
||||||
**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
|
||||||
**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes
|
|
||||||
**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files
|
|
||||||
**Implementation hold:** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete.
|
|
||||||
|
|
||||||
> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported.
|
|
||||||
|
|
||||||
## Execution invariants
|
|
||||||
|
|
||||||
- PostgreSQL is the sole writable SOT; current-main Drizzle is the persistence foundation.
|
|
||||||
- Mutations require fresh internal PostgreSQL transaction-local write proof and fail closed otherwise.
|
|
||||||
- Public health DTOs, Valkey, files, browser state, providers, and outage notes cannot authorize writes.
|
|
||||||
- Outage notes return only through attributable `change_proposals`; proposal acceptance executes the normal command.
|
|
||||||
- Mechanical Coordinator is non-LLM and cannot invent scope, waive gates, certify, or merge.
|
|
||||||
- Certifier is final independent gate with no merge authority.
|
|
||||||
- Workspace is the hard tenant. Project hierarchy is project-congruent. Assignment, approval, lease, fence, checkpoint, and evidence are relationally bound.
|
|
||||||
- Recovery tiers change recovery posture only.
|
|
||||||
|
|
||||||
## 1. Collision-free ownership
|
|
||||||
|
|
||||||
| USC lane | Exclusive ownership | Must not edit |
|
|
||||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
||||||
| **coder2 — schema/recovery** | `packages/db/src/schema.ts`; `packages/db/drizzle/**`; DB tests; `packages/config/src/recovery-posture.ts`; `packages/config/src/recovery-posture.spec.ts`; `docs/runbooks/kanban-postgres-recovery.md` | Gateway, Brain repositories, Coordinator, web, CLI/importer |
|
|
||||||
| **coder3 — domain/Gateway/MCP server** | Kanban repositories under `packages/brain/src/`; Gateway workspace/project/mission/milestone/task/kanban/health/coord modules; **exact MCP files:** `apps/gateway/src/mcp/mcp.dto.ts`, `mcp.controller.ts`, `mcp.service.ts`, `mcp.module.ts`, `mcp.tokens.ts`, `mcp.service.spec.ts`; Gateway root wiring/tests | DB schema/migrations, `packages/coord`, web, CLI/importer |
|
|
||||||
| **coder4 — CLI → pure Coordinator → migration tooling** | In this one fixed lane order: KBN-120 (`packages/mosaic` CLI/projection) → KBN-200 (`packages/coord/src/mechanical/**`) → KBN-300/320 (`scripts/kanban-migration/**`) | DB, Gateway/MCP server, web |
|
|
||||||
| **coder5 — web** | `apps/web/src/app/(dashboard)/{tasks,projects}/**`; `apps/web/src/components/{tasks,projects}/**`; Kanban web API/types; later Coordinator/migration-review routes | DB, Gateway, Coordinator, CLI/importer |
|
|
||||||
| **Mos — publication/integration** | Contract amendments, exact endpoint registry publication, serialized root exports/manifests/lockfiles, integration gates | Active lane feature files |
|
|
||||||
|
|
||||||
Shared roots, package exports/manifests, lockfiles, and generated artifacts are integration-serialized. Contract changes stop affected lanes and require Mos approval.
|
|
||||||
|
|
||||||
## 2. Parallelization legend
|
|
||||||
|
|
||||||
- **SERIAL:** prerequisite must be complete and reviewed.
|
|
||||||
- **PARALLEL-GROUP:** disjoint files and exact frozen contract permit concurrent work.
|
|
||||||
- **LANE-SERIAL:** one lane's stated order cannot change.
|
|
||||||
- **INTEGRATION-SERIAL:** component heads green first; semantic findings return to owner.
|
|
||||||
|
|
||||||
## 3. Corrected dependency graph
|
|
||||||
|
|
||||||
```text
|
|
||||||
KBN-000 canon remediation
|
|
||||||
-> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE)
|
|
||||||
-> KBN-100 schema + concrete N-1 migration implementation
|
|
||||||
├─ KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL)
|
|
||||||
│ ├─ KBN-110 domain + Gateway + MCP server implementation
|
|
||||||
│ ├─ KBN-120 CLI/projection implementation [coder4 first]
|
|
||||||
│ └─ KBN-130 web MVP implementation
|
|
||||||
└─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
|
|
||||||
KBN-110 + KBN-120 + KBN-130 + KBN-115
|
|
||||||
-> KBN-140 P1 integration/SIT
|
|
||||||
-> KBN-200 pure decision engine [coder4 after KBN-120]
|
|
||||||
-> KBN-210 persistence/service adapter + approval/lease binding
|
|
||||||
-> KBN-220 Coordinator operations UI
|
|
||||||
-> KBN-230 P2 concurrency/fault/gate integration
|
|
||||||
KBN-230
|
|
||||||
-> KBN-300 importer dry-run/apply/verify [coder4 after KBN-200]
|
|
||||||
├─ KBN-310 migration reviewer UI
|
|
||||||
└─ KBN-320 cutover/rollback tooling [coder4 after KBN-300]
|
|
||||||
KBN-310 + KBN-320
|
|
||||||
-> KBN-330 rehearsal/reconciliation
|
|
||||||
-> KBN-340 owner-gated cutover/stabilization
|
|
||||||
```
|
|
||||||
|
|
||||||
No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320.
|
|
||||||
|
|
||||||
## 4. P0 — Canon, threat gate, schema, and exact API freeze
|
|
||||||
|
|
||||||
### KBN-000 — Remediate and publish canon
|
|
||||||
|
|
||||||
- **Status:** COMPLETE — PR #752 squash-merged as `49e8a54`; issue #751 closed; post-merge pipeline #1798 terminal success.
|
|
||||||
- **Owner:** Mosaic publication control plane.
|
|
||||||
- **Mode:** SERIAL; completed.
|
|
||||||
- **IN:** Resolve KCR-001–016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review.
|
|
||||||
- **OUT:** Feature implementation.
|
|
||||||
- **Depends on:** none.
|
|
||||||
- **Contract surfaces:** all canon.
|
|
||||||
- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO; Ultron GO; terminal-green CI.
|
|
||||||
|
|
||||||
### KBN-010 — Threat, authorization, and constraint-impact gate
|
|
||||||
|
|
||||||
- **Status:** IN PROGRESS — issue [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753).
|
|
||||||
- **Owner:** `kbn-coder3`; independent `secrev`.
|
|
||||||
- **Mode:** SERIAL prerequisite of KBN-100.
|
|
||||||
- **Exclusive files:** `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and task scratchpad only.
|
|
||||||
- **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage.
|
|
||||||
- **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.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user