Compare commits

..

2 Commits

Author SHA1 Message Date
Jarvis
a02f526d0a fix(tess): route bare Discord approvals
Some checks failed
ci/woodpecker/pr/ci Pipeline was canceled
2026-07-13 05:10:48 -05:00
Jarvis
451f7e04ec feat(tess): wire durable interaction surfaces
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-13 04:43:22 -05:00
105 changed files with 156 additions and 10547 deletions

View File

@@ -7,7 +7,7 @@ import {
} 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 { TessDurableSessionService } from '../../agent/tess-durable-session.service.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
@@ -51,7 +51,7 @@ function authorization(): CommandAuthorizationService {
);
}
describe('interaction Discord/CLI durable-session integration', () => {
describe('Tess Discord/CLI durable-session integration', () => {
afterEach(() => {
for (const key of envKeys) {
const value = priorEnv.get(key);
@@ -80,7 +80,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
},
]);
const durable = new DurableSessionService(
const durable = new TessDurableSessionService(
new InMemoryDurableSessionStore() as never,
{} as never,
);

View File

@@ -12,24 +12,18 @@ type AgentServiceInternals = {
creating: Map<string, Promise<AgentSession>>;
};
function makeService(operatorMemory: unknown = null): AgentService {
function makeService(): AgentService {
return new AgentService(
{
getDefaultModel: vi.fn(() => null),
getRegistry: vi.fn(() => ({})),
findModel: vi.fn(),
listAvailableModels: vi.fn(() => []),
} as never,
{} 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,
{} as never,
{} as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
);
}
@@ -126,37 +120,6 @@ describe('AgentService owner/tenant scope enforcement', () => {
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();

View File

@@ -1,5 +1,5 @@
import { Global, Module } from '@nestjs/common';
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
@@ -11,8 +11,8 @@ import { SessionsController } from './sessions.controller.js';
import { AgentConfigsController } from './agent-configs.controller.js';
import { InteractionController } from './interaction.controller.js';
import { RoutingController } from './routing/routing.controller.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
import { TessDurableSessionService } from './tess-durable-session.service.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
@@ -20,7 +20,6 @@ import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
@@ -29,12 +28,6 @@ import {
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
const registry = new AgentRuntimeProviderRegistry();
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
return registry;
}
@Global()
@Module({
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
@@ -44,11 +37,11 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionRepository,
DurableSessionService,
TessDurableSessionRepository,
TessDurableSessionService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry,
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
},
RuntimeProviderAuditService,
{
@@ -76,7 +69,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionService,
TessDurableSessionService,
RuntimeProviderService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
],

View File

@@ -15,10 +15,9 @@ import {
type ToolDefinition,
} from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaicstack/brain';
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
import type { Memory } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js';
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
import { EmbeddingService } from '../memory/embedding.service.js';
import { CoordService } from '../coord/coord.service.js';
import { ProviderService } from './provider.service.js';
@@ -136,9 +135,6 @@ export class AgentService implements OnModuleDestroy {
@Inject(PreferencesService)
private readonly preferencesService: PreferencesService | null,
@Inject(SessionGCService) private readonly gc: SessionGCService,
@Optional()
@Inject(OPERATOR_MEMORY_PLUGIN)
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
) {}
/**
@@ -150,7 +146,6 @@ export class AgentService implements OnModuleDestroy {
private buildToolsForSandbox(
sandboxDir: string,
sessionUserId: string | undefined,
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
): ToolDefinition[] {
return [
...createBrainTools(this.brain),
@@ -159,9 +154,6 @@ export class AgentService implements OnModuleDestroy {
this.memory,
this.embeddingService.available ? this.embeddingService : null,
sessionUserId,
this.operatorMemory && sessionScope
? { plugin: this.operatorMemory, scope: sessionScope }
: undefined,
),
...createFileTools(sandboxDir),
...createGitTools(sandboxDir),
@@ -236,7 +228,6 @@ export class AgentService implements OnModuleDestroy {
isAdmin: options.isAdmin,
agentConfigId: options.agentConfigId,
userId: options.userId,
tenantId: options.tenantId,
conversationHistory: options.conversationHistory,
};
this.logger.log(
@@ -276,15 +267,7 @@ export class AgentService implements OnModuleDestroy {
}
// Build per-session tools scoped to the sandbox directory and authenticated user
const sessionUserId = mergedOptions?.userId;
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
const sandboxTools = this.buildToolsForSandbox(
sandboxDir,
sessionUserId,
sessionUserId && sessionTenantId
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
: undefined,
);
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
// Combine static tools with dynamically discovered MCP client tools and skill tools
const mcpTools = this.mcpClientService.getToolDefinitions();
@@ -379,7 +362,7 @@ export class AgentService implements OnModuleDestroy {
sandboxDir,
allowedTools,
userId: mergedOptions?.userId,
tenantId: sessionTenantId,
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
agentConfigId: mergedOptions?.agentConfigId,
agentName: resolvedAgentName,
metrics: {

View File

@@ -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' },
]);
});
});

View File

@@ -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();
});
});

View File

@@ -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')
);
}

View File

@@ -1,10 +1,6 @@
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 { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import { InteractionController } from './interaction.controller.js';
@@ -43,32 +39,6 @@ describe('InteractionController', (): void => {
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);

View File

@@ -17,7 +17,7 @@ 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 { TessDurableSessionService } from './tess-durable-session.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import {
RuntimeProviderService,
@@ -34,7 +34,7 @@ import {
export class InteractionController {
constructor(
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
) {}
@Get('sessions')
@@ -51,20 +51,6 @@ export class InteractionController {
);
}
@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,

View File

@@ -17,8 +17,6 @@ import type {
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
TransitionalCapabilityInventoryEntry,
TransitionalCapabilityInventoryProvider,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -30,8 +28,7 @@ export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
export type RuntimeProviderOperation =
| RuntimeCapability
| 'runtime.capabilities'
| 'runtime.health'
| 'runtime.transitional-capabilities';
| 'runtime.health';
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
@@ -74,15 +71,6 @@ 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();
@@ -163,25 +151,6 @@ export class RuntimeProviderService {
);
}
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,

View File

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

View File

@@ -6,8 +6,8 @@ import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/
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';
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
import { TessDurableSessionService } from './tess-durable-session.service.js';
const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova',
@@ -18,7 +18,7 @@ const IDENTITY: DurableSessionIdentity = {
runtimeSessionId: 'nova',
};
describe('DurableSessionRepository', () => {
describe('TessDurableSessionRepository', () => {
let dataDir: string | undefined;
let handle: DbHandle;
let previousAuthSecret: string | undefined;
@@ -48,7 +48,9 @@ describe('DurableSessionRepository', () => {
});
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));
const beforeRestart = new DurableSessionCoordinator(
new TessDurableSessionRepository(handle.db),
);
await beforeRestart.create(IDENTITY);
await beforeRestart.receive({
sessionId: IDENTITY.sessionId,
@@ -90,7 +92,7 @@ describe('DurableSessionRepository', () => {
await handle.close();
handle = createPgliteDb(dataDir!);
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
const recovered = await afterRestart.recover(IDENTITY.sessionId);
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
const handled: string[] = [];
@@ -118,7 +120,7 @@ describe('DurableSessionRepository', () => {
}, 30_000);
it('redacts sensitive durable payloads before persistence', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
@@ -155,7 +157,7 @@ describe('DurableSessionRepository', () => {
}, 30_000);
it('fails closed when the configured idempotency secret is unavailable', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const secret = process.env['BETTER_AUTH_SECRET'];
delete process.env['BETTER_AUTH_SECRET'];
@@ -175,7 +177,7 @@ describe('DurableSessionRepository', () => {
}, 30_000);
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const input = {
sessionId: IDENTITY.sessionId,
@@ -225,7 +227,7 @@ describe('DurableSessionRepository', () => {
}, 30_000);
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const inbox = {
sessionId: IDENTITY.sessionId,
@@ -253,7 +255,7 @@ describe('DurableSessionRepository', () => {
}, 30_000);
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
@@ -291,10 +293,10 @@ describe('DurableSessionRepository', () => {
}, 30_000);
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
const repository = new DurableSessionRepository(handle.db);
const repository = new TessDurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const service = new TessDurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'live-effect',
@@ -331,10 +333,10 @@ describe('DurableSessionRepository', () => {
}, 30_000);
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
const repository = new DurableSessionRepository(handle.db);
const repository = new TessDurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const service = new TessDurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'mismatch-effect',
@@ -364,10 +366,10 @@ describe('DurableSessionRepository', () => {
}, 30_000);
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
const repository = new DurableSessionRepository(handle.db);
const repository = new TessDurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const service = new TessDurableSessionService(repository, runtimeProviders as never);
const first = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'scoped-effect-one',

View File

@@ -33,7 +33,7 @@ import type {
import { DB } from '../database/database.module.js';
@Injectable()
export class DurableSessionRepository implements DurableSessionStore {
export class TessDurableSessionRepository implements DurableSessionStore {
constructor(@Inject(DB) private readonly db: Db) {}
async create(identity: DurableSessionIdentity): Promise<void> {
@@ -51,7 +51,7 @@ export class DurableSessionRepository implements DurableSessionStore {
const existing = await this.session(identity.sessionId);
if (!existing || !sameEnrollmentScope(existing, identity)) {
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
throw new Error(`Durable Tess 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.
@@ -135,13 +135,13 @@ export class DurableSessionRepository implements DurableSessionStore {
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
if (!existing[0]) throw new Error(`Durable Tess 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}`);
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
@@ -224,13 +224,14 @@ export class DurableSessionRepository implements DurableSessionStore {
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
if (!existing[0])
throw new Error(`Durable Tess 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}`);
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
@@ -337,7 +338,7 @@ export class DurableSessionRepository implements DurableSessionStore {
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
) {
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
}
}
@@ -359,7 +360,7 @@ export class DurableSessionRepository implements DurableSessionStore {
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}`);
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
}
const inserted = await this.db
.insert(interactionHandoffs)
@@ -370,7 +371,7 @@ export class DurableSessionRepository implements DurableSessionStore {
const existing = await this.findHandoff(input.handoffId);
if (!existing || !sameHandoff(existing, input)) {
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
}
}

View File

@@ -1,23 +1,23 @@
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 type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
import { TessDurableSessionRepository } from './tess-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
* Scoped gateway boundary for the canonical Tess 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 {
export class TessDurableSessionService {
private readonly coordinator: DurableSessionCoordinator;
constructor(
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
) {
this.coordinator = new DurableSessionCoordinator(repository);
@@ -32,12 +32,12 @@ export class DurableSessionService {
identity.ownerId !== context.actorScope.userId ||
identity.tenantId !== context.actorScope.tenantId
) {
throw new ForbiddenException('Durable session enrollment scope mismatch');
throw new ForbiddenException('Durable Tess enrollment scope mismatch');
}
await this.coordinator.create(identity);
}
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
async queueProviderSend(input: TessProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(input.sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.enqueueOutbox({
@@ -50,9 +50,9 @@ export class DurableSessionService {
});
}
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
if (sessionId !== input.sessionId) {
throw new ForbiddenException('Durable outbox session mismatch');
throw new ForbiddenException('Durable Tess outbox session mismatch');
}
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
@@ -92,7 +92,7 @@ export class DurableSessionService {
}
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.recover(sessionId);
@@ -100,24 +100,24 @@ export class DurableSessionService {
private assertOutboxScope(
entry: { kind: string; correlationId: string; channelId: string },
input: ProviderOutboxDto,
input: TessProviderOutboxDto,
): void {
if (
entry.kind !== 'provider.send' ||
entry.correlationId !== input.correlationId ||
entry.channelId !== input.context.channelId
) {
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch');
}
}
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): 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');
throw new ForbiddenException('Durable Tess session scope or correlation mismatch');
}
}
}

View File

@@ -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',
});
});
});

View File

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

View File

@@ -37,7 +37,7 @@ import {
RuntimeProviderService,
type RuntimeAuditSink,
} from '../agent/runtime-provider-registry.service.js';
import { DurableSessionService } from '../agent/durable-session.service.js';
import { TessDurableSessionService } from '../agent/tess-durable-session.service.js';
import { AUTH } from '../auth/auth.tokens.js';
import {
scopeFromUser,
@@ -140,8 +140,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@Inject(RuntimeProviderService)
private readonly runtimeRegistry: RuntimeProviderService | null = null,
@Optional()
@Inject(DurableSessionService)
private readonly durableSessions: DurableSessionService | null = null,
@Inject(TessDurableSessionService)
private readonly durableSessions: TessDurableSessionService | null = null,
@Optional()
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
private readonly runtimeAudit: RuntimeAuditSink | null = null,

View File

@@ -1,32 +1,10 @@
import { Module } from '@nestjs/common';
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
import { CoordService } from './coord.service.js';
import { CoordController } from './coord.controller.js';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
import {
COORDINATION_CONFIG,
COORDINATION_PORT,
InteractionCoordinationService,
} from './interaction-coordination.service.js';
@Module({
providers: [
CoordService,
{
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],
providers: [CoordService],
controllers: [CoordController],
exports: [CoordService],
})
export class CoordModule {}

View File

@@ -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();
});
});

View File

@@ -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,
};
}
}

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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' });
});
});

View File

@@ -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;
}
}

View File

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

View File

@@ -10,9 +10,9 @@
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
**Phase:** Execution (workstream W1 in planning-complete state)
**Current Workstream:** W1 — Federation v1
**Progress:** 0 / 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)
**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
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
@@ -67,12 +67,11 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
## Workstreams
| # | ID | Name | Status | Manifest | Notes |
| --- | ---- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460#466 filed |
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
| W3 | KBN | Native Kanban and canonical task SOT | planning-complete | [docs/native-kanban-sot/MISSION-MANIFEST.md](./native-kanban-sot/MISSION-MANIFEST.md) | P0P3; issue #751; implementation held until canon merge |
| W4+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
| # | ID | Name | Status | Manifest | Notes |
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460#466 filed |
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
### Likely Additional Workstreams (Not Yet Declared)

View File

@@ -79,52 +79,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
---
## Fleet Declarative Configuration Management Workstream (FCM, #758)
### Problem and objective
The local Mosaic fleet has a roster, generated agent environment files, user-systemd units, tmux
sessions, heartbeat files, examples, profiles, and separate gateway-backed agent records. These
planes have drifted and are not one safe operator lifecycle. The objective is one **local fleet
roster** as the desired-state SSOT, with generated environment, systemd, tmux, and heartbeat
artifacts as rebuildable projections; it does not merge the local fleet control plane with the
gateway-backed agent catalog.
### Normative requirements
| ID | Requirement |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
| `FCM-REQ-09` | M1M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
### Acceptance criteria
1. `AC-FCM-01`: A valid local v2 roster can be parsed from YAML or JSON, validated structurally and semantically through the shared resolver, and rendered canonically; invalid fields, duplicate names, unresolved classes, unsupported runtime/model combinations, socket ambiguity, and incompatible options fail closed.
2. `AC-FCM-02`: `plan` reports deterministic desired-versus-observed differences for roster, generated environment, systemd enablement, tmux/session, heartbeat, installed-asset revision, and provable orphans without mutation; `apply --check` reports drift without mutation.
3. `AC-FCM-03`: Local create/update/delete is generation-guarded, atomic, idempotent, and safe by default; it permits supported runtime/model/harness/effort/workdir/role changes without direct editing of generated environment files and does not start a newly created agent unless explicitly persisted.
4. `AC-FCM-04`: The generated-env/local-override launch chain rejects generated-key shadowing, arbitrary command override, unknown keys, shell evaluation, and sensitive-value diagnostics before any agent starts; known-safe legacy input is regenerated or strictly relocated, and forbidden input is quarantined.
5. `AC-FCM-05`: Local lifecycle reconciliation implements the persisted/transient start-stop rules, exact default/named tmux socket targeting, systemd/tmux status, stale generated state, unmanaged-session reporting, and rollback without surprise restarts or fuzzy destructive targeting.
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
### M0 implementation gate
No source, schema, role, example, profile, systemd, or live-fleet change is authorized before M0
lands. M0 consists only of these normative requirements, the complete task DAG, the scoped
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
---
## Tess Interaction Agent Workstream (TESS)
### Problem and Objective

View File

@@ -1,50 +0,0 @@
# Documentation Sitemap
## Native Kanban and canonical task SOT
- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0P3 requirements and acceptance criteria.
- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order.
- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model.
- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries.
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001016 findings that blocked the first draft.
- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict.
- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review.
## Tess interaction agent
### Operator guides
- [User guide](tess/USER-GUIDE.md) — authorized session, attach, send, stop, and handoff workflows.
- [Admin guide](tess/ADMIN-GUIDE.md) — deployment configuration, policy, and approval controls.
- [Developer guide](tess/DEVELOPER-GUIDE.md) — provider contracts, scope boundaries, and test workflow.
- [Plugin guide](tess/PLUGIN-GUIDE.md) — adapter, redaction, and identity-as-data requirements.
- [Operations guide](tess/OPERATIONS-GUIDE.md) — readiness, recovery, and incident-safe procedures.
### Architecture and security
- [Architecture](tess/ARCHITECTURE.md)
- [Threat model](tess/THREAT-MODEL.md)
- [Mos coordination boundary](tess/MOS-COORDINATION.md)
- [Hermes runtime adapter design](tess/hermes-runtime-adapter-design.md)
- [Operator plugin sketch](tess/M4-003-OPERATOR-PLUGIN-SKETCH.md)
### API contract
- [Tess OpenAPI contract](openapi-tess.yaml)
### Migration and qualification
- [Migration inventory](tess/M5-MIGRATION-INVENTORY.md)
- [Cutover procedure](tess/M5-MIGRATION-CUTOVER.md)
- [Rollback procedure](tess/M5-MIGRATION-ROLLBACK.md)
- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md)
- [Verification matrix](tess/VERIFICATION-MATRIX.md)
- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md)
- [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)

View File

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

View File

@@ -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.

View File

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

View File

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

View File

@@ -1,95 +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` | M1M5 support local tmux only. |
| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. |
| `defaults` | yes | strict object | Default work directory and one supported local runtime. |
| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. |
| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
## Nested fields
| Path | Required | Constraint |
| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity |
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
| `defaults.working_directory` | yes | non-empty string |
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
| `agents[].alias` | yes | non-empty display string |
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
| `agents[].reasoning` | yes | `low`, `medium`, or `high` |
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
## Fail-closed boundary
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket
per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields,
and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently
ignore v1 camelCase input, version `1`, or a source that does not parse to an object.
The v2 compiler is intentionally isolated from the existing v1 loader. Existing v1 rosters and
current examples/profiles continue on their current path; FCM-M4 owns explicit inventory, preview,
migration, and rollback. FCM-M2 owns generated-file/local-override quarantine, and FCM-M3 owns
runtime lifecycle and reconciliation.

View File

@@ -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"
}
}
}
}
}
}
}
}

View File

@@ -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-001016 closure was independently verified.
- [x] Ultron final gate returned GO with zero BLOCKER/HIGH findings.
- [x] Formatter, lint, typecheck, strict contract TypeScript, link, scope, and invariant publication validation passed in the current Stack toolchain.
- [ ] PR review, CI, squash merge, and issue closure remain required before publication completion.
## Publishing
- [x] Canonical source remains in-repository.
- [x] No external publishing platform is required for this internal architecture contract.

View File

@@ -1,64 +0,0 @@
# Native Kanban/SOT Canon
**Status:** KCR-001016 independently cleared; canonical publication is in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
**Date:** 2026-07-14
**Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied.
## Artifacts
| Artifact | Purpose |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria |
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership |
| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization |
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults |
| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations |
| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals |
| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001016 findings that blocked the first draft |
| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
## Recommended USC lane partition
| Lane | Natural seam | Exclusive ownership |
| ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **coder2** | Schema + migrations + recovery slice | Unified Drizzle schema, migration SQL/meta/journal/tests, then recovery parser/mechanism/runbook files |
| **coder3** | Domain + Gateway + MCP server | Workspace-safe repositories, DTOs/controllers/services, exact `apps/gateway/src/mcp/**` files, health proof, proposals, Coordinator persistence adapter |
| **coder4** | Pure Coordinator + tooling | `packages/coord` mechanical engine, CLI/MCP consumers, generated projection, one-way importer and cutover tooling; lane-serialized internally |
| **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI |
| **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority |
The safe order is KBN-010 → KBN-100 → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files.
## Recovery defaults
| Tier | RPO / RTO | WAL / PITR | Base backup | Restore / break-glass | Off-cluster |
| -------------- | ------------ | ------------------- | ----------- | ----------------------- | ----------------------------------------------- |
| Lite | 24h / 24h | disabled / disabled | daily | quarterly / annual | encrypted separate target |
| Standard | 1h / 8h | q15m / 14d | daily | quarterly / semiannual | encrypted separate object storage |
| High-assurance | **15m / 4h** | **q5m / 35d** | **daily** | **monthly / quarterly** | **encrypted base+WAL, separate failure domain** |
These knobs affect recovery posture only. PostgreSQL remains the sole writable SOT in every tier. Fail-closed writes, generated-file non-authority, attributable post-recovery proposals, non-LLM Coordinator limits, and Certifier final-gate/no-merge authority are fixed for every tier.
## Non-blocking implementation sub-decisions for Mos
The source plan and ratified seven decisions resolve all build-blocking product choices. The following implementation-local selections remain for the owning slices/Mos and must not weaken v1:
1. Exact PostgreSQL write-health probe SQL and bounded proof lifetime; authority and failures are frozen.
2. Dependency-cycle serialization mechanism (recursive CTE plus transaction/advisory lock or equivalent); required behavior is frozen.
3. Whether RLS lands in the first migration or immediately after the tested session-context pattern; workspace constraints/repository authorization are required from migration one.
4. Concrete off-cluster backup provider/bucket and selected production recovery tier; High-assurance minima are frozen if selected.
5. Cutover reconciliation thresholds and stabilization duration, to be owner-approved before P3 execution.
None authorizes a second writer, dual sync, LLM scheduling, Coordinator gate waiver/merge, or Certifier merge authority.
## Publication validation evidence
- Concrete TypeScript contracts are formatted with repository Prettier.
- All four contracts pass strict TypeScript no-emit checking against the current Stack Drizzle toolchain.
- Contract remediation and KCR-001016 traceability are recorded in the issue scratchpad and linked review reports.
- Independent re-review returned GO with KCR-001016 closed; implementation remains held until canon merge and the dependency-ordered KBN prerequisites complete.

View File

@@ -1,195 +0,0 @@
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0P3
**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
**Date:** 2026-07-14
**Human decision owner:** Jason
**Orchestrator/publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage)
**Execution topology:** USC web1, partitioned across collision-free GPT coder2/3/4/5 lanes
**Canonical requirements:** [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md)
**Frozen integration contract:** `SHARED-CONTRACT.md` and `contracts/*.v1.ts`
## 1. Mission statement
Extend current `mosaicstack/stack` main into the sole native control plane for workspace-scoped project, mission, milestone, task, dependency, assignment, lease, approval, evidence, and audit state. First deliver a thin writable Kanban/List vertical slice; then add deterministic mechanical coordination and execute a one-way migration/cutover from jarvis-brain/Vikunja project/task stores.
Success means every user, agent, orchestrator, specialist, and UI sees and mutates the same PostgreSQL aggregate revisions through typed Gateway commands, with no writable fallback and no hidden second authority.
## 2. Scope boundaries
### In scope
- Current Drizzle/PostgreSQL schema extension and migrations.
- Workspace tenancy and authorization from the first migration.
- Projects, missions, milestones, tasks, normalized tags, dependencies, assignments, durable execution/quarantine state, links, immutable artifacts/evidence joins, outage change proposals, events, approvals, leases, checkpoints, and transactional outbox.
- NestJS Gateway queries and explicit lifecycle commands.
- MCP/CLI agent surfaces and generated read-only projections.
- Thin writable Next.js Tasks Kanban/List, task detail, minimal Projects CRUD, filters, dependency readiness, ownership/lease separation, and audit timeline.
- Non-LLM Mechanical Coordinator eligibility, proposal, approval-policy, lease/fence, heartbeat, retry, expiry, quarantine, and restart recovery.
- Planning, Enhance, Coder, Review, SecReview, PR-Monitor, and Certifier role/gate representation.
- One-way shadow importer, reconciliation, write freeze, final delta, cutover, rollback package, and legacy read-only stabilization.
- Recovery-posture configuration and health-state/fail-closed contract.
### Out of scope
- Greenfield services, Prisma runtime revival, or jarvis-brain flat files as runtime storage.
- Writable Markdown/JSON/Valkey/browser/provider fallback.
- Gitea issue/PR replacement or generic bidirectional provider sync.
- Calendar, email, GLPI cache, CRM, billing, time tracking, personal-brain migration.
- LLM scheduling or scope interpretation by the Coordinator.
- Autonomous gate waiver, certification, merge, release, deployment, or issue closure by Coordinator.
- Merge authority for Certifier.
- P4 full portfolio/mission designer and P5 fleet-scale policy unless separately released.
## 3. Fixed invariants
Every deployment MUST preserve all of the following:
1. PostgreSQL is the sole writable SOT.
2. Drizzle on current stack main is the only persistence foundation.
3. Mutations fail closed when DB write-health cannot be proven `healthy`.
4. No file, Valkey, browser, queue, provider, or human note becomes a fallback writer.
5. `TASKS.md`, `mission.json`, and every file export are generated, read-only, non-authoritative, and never import sources.
6. Human outage notes become attributable post-recovery proposals only.
7. Workspace is the hard tenant; Team is intra-workspace authorization.
8. Valkey is expendable; PostgreSQL owns state, leases, fencing, audit, and outbox.
9. Mechanical Coordinator is deterministic/non-LLM and cannot invent scope, waive gates, certify, or merge.
10. Certifier is the final independent quality gate and has no merge authority.
11. Mutations use idempotency and optimistic aggregate versions; worker commands also require a current fencing token.
12. Recovery tier changes only backup/recovery posture, never authority or gate semantics.
## 4. Configurable recovery posture
Deployments select Lite, Standard, or High-assurance defaults from [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md) and `contracts/recovery-posture.v1.ts`. Configurable fields are limited to:
- backup/base-backup cadence;
- RPO and RTO targets;
- PITR retention;
- WAL archive cadence;
- restore-test frequency;
- break-glass drill frequency;
- encrypted off-cluster storage.
High-assurance defaults are fixed reference values: RPO 15 minutes, RTO 4 hours, encrypted off-cluster WAL every 5 minutes with 35-day PITR, daily base backup, monthly restore test, and quarterly break-glass drill.
## 5. Canonical role map
```text
User
↓ objectives, constraints, ratified decisions
Interaction Layer
↓ workspace/project context; no scheduling authority
Portfolio Orchestrator
↓ approved mission, cross-project priority/capacity
Project Sub-Orchestrator
↓ decomposition, DAG, acceptance, release, routing policy, overrides
Gateway
↓ authenticated/authorized typed commands
Project/Task Domain Services
↓ transactional state + semantic event + outbox
Mechanical Coordinator
↓ deterministic eligibility/proposal/lease/fence/retry/quarantine
Specialists
Planning → Enhance → Coder → Review → conditional SecReview → remediation
↓ complete evidence bundle
Certifier
↓ final pass/reject/escalate; NO merge authority
Project Sub-Orchestrator / control plane
↓ merge authority after all gates
Post-merge validation
```
### Authority table
| Role/layer | Owns | Explicitly cannot do |
| ------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| User | Objectives, constraints, Jason-owned decisions | Direct DB/file authority bypass |
| Interaction | Conversation and context resolution | Schedule, approve, lease, certify |
| Portfolio Orchestrator | Mission approval, cross-project priority/capacity/global holds | Implement or self-certify specialist work |
| Project Sub-Orchestrator | Task decomposition/DAG/acceptance, release to ready, routing policy, overrides, remediation, merge go-ahead | Bypass required independent gates |
| Gateway | Identity, tenancy, DTO validation, commands, state-machine enforcement | Accept file edits or client SQL as mutations |
| Domain services | Transactional business invariants, semantic events/outbox | Depend on Valkey/files for committed truth |
| Mechanical Coordinator | Eligibility, dependencies, proposal, approved routing, lease/fence, heartbeat, retry/quarantine | Invent/alter scope, waive gates, certify, merge |
| Specialists | Bounded planning/implementation/review artifacts under a task lease | Modify another lane's owned files or self-approve |
| Certifier | Final independent evidence/traceability/gate decision | Merge, close provider issue, release, waive policy |
## 6. Gate model
### Mandatory gates
1. Requirements/contract freeze before parallel implementation.
2. P0 schema/authority threat model and tenant isolation review.
3. Author and reviewer MUST be different principals/sessions.
4. Functional review validates requirements, endpoint registry, concurrency, and negative paths.
5. **Mandatory SecReview (`secrev`)** for any auth, authorization, tenant, service-token, secret, database schema/migration, data-integrity, import/cutover, audit, lease/fencing, recovery, or destructive-retirement surface.
6. Review findings enter bounded remediation owned by the implementation lane.
7. Raising reviewer re-verifies remediation.
8. Certifier performs the final independent evidence and traceability gate.
9. Merge authority remains with `mos-claude`/Project Sub-Orchestrator control plane after gates pass.
10. Post-merge CI and situational validation must be terminal green before closure.
### Gate outcomes
- **PASS:** evidence complete; next authority may proceed.
- **REJECT:** findings are explicit and route to remediation.
- **ESCALATE:** policy/owner decision required; no implicit waiver.
No role can transform a missing gate into a warning by changing status, editing a projection, or writing Valkey.
## 7. Slice ownership rules
1. USC web1 is the sole execution environment; coder2/3/4/5 are independent bounded lanes under Mos.
2. Every slice has one named file-tree owner and an explicit IN/OUT boundary in `TASKS.md`.
3. Two active slices MUST NOT edit the same source file, migration file, generated snapshot, lockfile, or API contract.
4. coder2 exclusively owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, migration journal/meta/tests, then its disjoint recovery-parser/runbook slice. All schema requests serialize through coder2.
5. Frozen `contracts/*.v1.ts` are read-only inputs during implementation. Contract changes require Mos approval, a version bump/amendment, and coordinated rebase before work resumes.
6. coder3 exclusively owns Gateway DTO/controllers/services and the enumerated `apps/gateway/src/mcp/**` server files. coder4 owns CLI/projection clients and never edits MCP server files. Web consumers use the exact KBN-105 endpoint/DTO freeze.
7. coder4 executes one lane order: CLI/projection → pure Coordinator → importer → cutover. The pure Coordinator under `packages/coord` does not load IDs or access DB, Gateway, Valkey, recovery I/O, or web files; coder3 owns the persistence/service adapter.
8. Migration/import tooling calls Gateway/migration-only approved ports and does not add a second database model.
9. Each lane commits only its owned files and reports any needed cross-slice change as a contract-change request instead of editing another lane's tree.
10. Cross-review is mandatory: no lane reviews its own changes. Recommended ring is coder2 ← coder5, coder3 ← coder2, coder4 ← coder3, coder5 ← coder4, followed by independent SecReview where triggered and Certifier final.
11. Integration-only edits are a separate serialized slice after component lanes are green; no opportunistic merge-conflict resolution may alter semantics.
## 8. Delivery phases and exit gates
### P0 — Canon and authority foundation
- Publish this canon, frozen schema/ports/health/recovery contracts, threat model, authorization matrix, exact endpoint/DTO registry, concrete current-main field-by-field migration map, and standards amendment.
- Build hold remains active until independent author≠reviewer re-review returns GO on health proof/failures, approval binding, fencing, tenant relationships, proposals, migration map, slice ordering/API freeze, recovery validation, and vocabulary alignment.
- Exit: no unresolved second writer or contract blocker, tenant boundary frozen, all seven decisions traceable, and independent re-review GO recorded.
### P1 — Thin native MVP
- Schema/migration, tenant-safe Gateway, CLI/MCP/projection, writable Kanban/List/Projects, dependencies/readiness/audit.
- Exit: same revision across web/CLI/MCP/projection; cross-workspace tests fail closed; generated files cannot mutate state.
### P2 — Mechanical coordination
- Agent/session registry, deterministic engine, approval queue, PostgreSQL leases/fencing/checkpoints/outbox, retry/quarantine, operations UI.
- Exit: one lease winner, stale tokens rejected, dependencies/approvals enforced, DB/Valkey fault semantics proven, Certifier gate has no merge authority.
### P3 — Shadow migration and cutover
- Importer, lineage, reconciliation, reviewer UI, write freeze, final delta, Gateway switch, legacy read-only, stabilization and rollback package.
- Exit: signed reconciliation, zero active legacy writers, scoped Gateway identities, imported backlog cannot dispatch accidentally.
## 9. Evidence required for mission closure
- Requirement-to-test/evidence matrix.
- Schema/migration and N-1 rolling-deploy proof.
- Cross-workspace API/repository/import/Coordinator negative tests.
- Health-state and fail-closed fault injection.
- Valkey-loss/outbox replay and Coordinator restart tests.
- Concurrent lease and stale fencing tests.
- Endpoint-registry alignment across web/CLI/MCP/Gateway.
- Accessible real-Gateway Kanban journeys.
- Generated projection tamper/no-import proof.
- One-way migration dry-run/apply/verify and field reconciliation.
- Author-independent functional review and required SecReview.
- Certifier final decision and evidence bundle.
- Merged main SHA, terminal green CI, closed linked task/issue, and post-merge situational validation under orchestrator ownership.
## 10. Change control
This manifest is derived from the ratified source plan. Any change to SOT authority, workspace tenancy, fixed statuses, Coordinator/Certifier authority, health-state semantics, schema v1, migration direction, or recovery-tier field set is a contract change. Contract changes require Jason/Mos authorization and cannot be inferred by an implementation lane.
No coder lane may start while the build hold is active. KBN-010 must complete before KBN-100; KBN-105 exact endpoint/DTO freeze must complete before any API consumer implementation.

View File

@@ -1,219 +0,0 @@
# Native Kanban/SOT — Remediated Shared Contract v1
**Status:** INDEPENDENT REVIEW GO; freezes as v1 when issue #751 canon merges to `main`
**Version:** 1.0.0-rc.3
**Date:** 2026-07-14
**Change authority:** Mosaic control plane/Jason only
## 1. Authority
Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied.
## 2. Health proof and exact failures
`KanbanHealthResponseV1` is a discriminated union:
| State | read | write | Capability |
| -------------------- | ----: | ----: | --------------------------------------------------- |
| `healthy` | true | true | reads; public state still cannot authorize mutation |
| `read-only-degraded` | true | false | reads only |
| `write-unavailable` | false | false | diagnostics only |
Every response has `checkedAt`, `validUntil`, `policyRevision`; contradictory booleans fail validation.
For a mutation, Gateway opens the PostgreSQL transaction, executes the live write probe on that transaction/connection, mints the internal branded `PostgresWriteHealthProofV1`, and revalidates time/policy/transaction identity immediately before mutation. Public REST/MCP/CLI DTOs never accept health/proof fields. Valkey/caller assertions cannot mint proof. Pure Coordinator takes `KanbanEvaluationContextV1`; persistence takes `InternalKanbanMutationContextV1` or probes internally.
| Case | HTTP | Frozen result | Retry |
| ---------------------------- | --------------------------: | ------------------------------------------------------------------- | -------------------- |
| degraded write | 503 | `KANBAN_WRITE_HEALTH_UNPROVEN`, `read-only-degraded`, `not_applied` | false |
| write unavailable | 503 | `KANBAN_WRITE_UNAVAILABLE`, `write-unavailable`, `not_applied` | false |
| version conflict | 409 | `AGGREGATE_VERSION_CONFLICT`, actual version, `not_applied` | false |
| timeout/unreachable | timeout/502/504 | `retryable_transport_error`, `unknown` | same idempotency key |
| stale fence/session/approval | coordinator rejection union | `not_applied` | false |
Required negatives: contradictory state, expired/policy-mismatched/wrong-transaction proof, Valkey-only health, forged healthy, and exhaustive non-cross-mapping of 503 vs 502/504/timeout vs 409.
## 3. Canonical schema invariants
Complete declaration: `contracts/kanban-schema.v1.ts`.
- Tables: tenant/identity (`workspaces`, members, teams/members, agents/sessions); planning (`projects`, `milestones`, current-milestone join, `missions`, mission-milestones, `tasks`, normalized tags, dependencies); orchestration (`task_assignments`, durable execution state, leases, checkpoints/evidence); governance (`change_proposals`, immutable artifacts/evidence, events, approvals, outbox, external links).
- Task statuses: `backlog | ready | in_progress | blocked | in_review | done | cancelled`.
- Assignment states everywhere: `awaiting_approval | policy_pre_authorized | approved | rejected | leased | released | expired | superseded`.
- Specialist roles everywhere: `planning | enhance | coder | review | security-review | pr-monitor | certifier`.
- Owner uses exactly-one user/team; assignment principal exactly-one user/team/agent; users require active membership; agent/session and all evidence are workspace-bound.
- Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations.
- Dependency identity is workspace+predecessor+successor independent of type.
- Approval evidence and checkpoint evidence are workspace-scoped joins to immutable artifacts, never JSON ID arrays.
- Proposal audit links are composite relations: `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` reference `task_events(workspace_id, id)` with RESTRICT deletion.
- Assignment is persisted with task/version, exact target/session, expiry/state/policy/proposer/reason. Approval relates to assignment. Lease acquisition accepts IDs, then reloads/locks and validates every relation.
- `tasks.fencing_counter` is bigint; locked atomic increment/RETURNING creates a decimal-string lease token. Lease/checkpoint composites bind exact workspace+task+assignment/session+fence.
- `task_execution_states` durably records retry/quarantine/exhaustion.
- Tags are normalized; legacy `tasks.tags` remains through N-1. Archive is explicit actor/reason/time and does not change lifecycle.
- Canonical parents use RESTRICT. Events/checkpoints/artifacts/evidence are INSERT/SELECT-only for application roles. Normal flow archives/cancels; purge is audited break-glass retention work.
## 4. Outage proposal contract
`change_proposals` stores workspace, active-member proposer, source-note digest, target/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, proposal version, and submit/accepted event IDs. Both event IDs are workspace-aware composite foreign keys to `task_events(workspace_id, id)`; a bare UUID is never sufficient.
Submission preallocates the proposal ID. One transaction inserts `change_proposal.submitted` with the proposal workspace, `aggregate_type='change_proposal'`, `aggregate_id=<new proposal ID>`, `previous_version=NULL`, and `new_version=1`, then inserts the proposal referencing that event. Missing, foreign-workspace, wrong-type, or unrelated-proposal events abort the transaction.
Submit/list/get/accept/reject are explicit Gateway commands. Pending/rejected proposals are inert: no scheduling, dependency/gate satisfaction, or direct target mutation. Acceptance locks proposal+target, obtains fresh transaction-local proof, verifies pending/expected version, invokes the normal command handler, and atomically stores the emitted normal-command event ID. That event must share the proposal workspace, match `target_aggregate_type` and `target_aggregate_id`, use `causation_id=submitted_audit_event_id`, and carry `payload.changeProposalId=<locked proposal ID>`. Missing, foreign-workspace, unrelated-target, unrelated-proposal, or unrelated-command events abort acceptance.
## 5. Concrete current-main N-1 migration delta
**Inspected:** `origin/main:packages/db/src/schema.ts` at `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13). It has global teams/no workspace keys, legacy project/mission/task statuses, nullable task project/mission, `tasks.assignee/tags/due_date`, mission JSON/config, duplicated `mission_tasks.status`, legacy agent fields, and separate fleet `backlog` claims.
Legacy columns remain declared in unified `schema.ts` for expand + full N-1/rollback window. Generation must not infer early drops.
### 5.1 Ordered phases
1. **Pre-expand:** N-1 patch stops `mission_tasks.status` as write source; inventory writers; backup/checksum.
2. **Expand:** add enums/tables and nullable-first columns; retain legacy declarations/uniques; emit no v1-only status.
3. **Backfill:** bootstrap workspace; bounded idempotent cursor/checksum batches; quarantine ambiguous rows.
4. **Validate:** no null tenant, cross-project link, ambiguous owner; status/tag/date/config retention; then constraints/NOT NULL.
5. **Compatibility:** N-1 reads legacy; same-DB transaction mirrors only unavoidable fields; never file/Valkey dual write.
6. **Switch:** stop N-1 writers; Gateway sole command boundary; enable canonical statuses.
7. **Contract release:** later release after rollback/N-1; remove compatibility/global uniques/legacy fields.
### 5.2 New audit/proposal DDL order
KBN-100 migration DDL must execute in this order:
1. create `task_events` and its unique `(workspace_id, id)` key;
2. create `change_proposals` with nullable acceptance-event ID and required submission-event ID;
3. add `change_proposals_workspace_submitted_event_fk` from `(workspace_id, submitted_audit_event_id)` to `task_events(workspace_id, id)` with `ON DELETE RESTRICT`;
4. add `change_proposals_workspace_accepted_command_event_fk` from `(workspace_id, accepted_command_audit_event_id)` to the same composite key with `ON DELETE RESTRICT`;
5. install application-role immutability privileges and same-transaction semantic validation before enabling proposal commands.
The submission transaction inserts the event first using a preallocated proposal UUID, then the proposal. Acceptance inserts the normal command event before updating the locked proposal. Neither FK is omitted or replaced by a bare UUID/index check.
### 5.3 Field map
| Current | Expand/backfill | N-1 compatibility | Switch/contract |
| ---------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |
| global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later |
| `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later |
| project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity |
| current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority |
| nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later |
| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review |
| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority |
| mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off |
| mission config/metadata/phase/user | retain all; map known typed policy only | all remain declared | remove only by signed consumer inventory |
| nullable `tasks.project_id` | derive explicit/mission project; orphan quarantine | retain nullable read/write during compatibility | canonical required; NOT NULL later |
| `tasks.mission_id` | add project-congruent composite | old relation readable | composite authority |
| `tasks.status` | canonical: not-started→backlog, in-progress→in_progress, others same | no ready/in_review emission | canonical authority |
| `tasks.assignee` | deterministic active user/team/agent assignment; raw value preserved if ambiguous | mirror text only if unambiguous | canonical owner/assignment; remove after no-loss sign-off |
| `tasks.tags` JSON | normalize trim/case/dedupe with original digest | transactionally mirror normalized rows | normalized authority; JSON later removed |
| `tasks.due_date` | copy exactly to `due_at` | mirror | due_at authority; legacy later |
| task common fields | preserve metadata byte-for-byte; add criteria/rank/retry/archive/version/fence | old reads valid | new fields canonical |
| `mission_tasks.status` | keep; prohibit as write source; linked status ignored; unlinked becomes task or reject | read-only compatibility value | membership uses task mission; status dropped after no readers |
| mission-task notes/PR/user | map to metadata/artifact/event/link/attribution; preserve | read-only | remove after parity |
| `agents.status` | add workspace/lifecycle/runtime/roles; status remains presence | retain all legacy fields | lifecycle/roles authority; status may remain telemetry |
| agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory |
| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization |
### 5.4 Required migration tests
Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release.
Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation.
## 6. Ownership and Coordinator split
coder2 solely owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, journal/metadata, and migration tests. No other lane generates migrations. Expand is additive; no drop/rename/narrow; constraints validate before NOT NULL; compatibility is same-DB only; contract is later.
KBN-200/coder4 owns pure `MechanicalCoordinatorDecisionEngineV1`: complete immutable snapshots in, deterministic eligibility/proposal/retry decisions out; no ID loading, SQL, Gateway, Valkey, proof, persistence, restart I/O, or LLM.
KBN-210/coder3 owns `MechanicalCoordinatorServicePortV1`: ID loading, locks, fresh proof, assignment/approval persistence, atomic fencing, lease/checkpoint/outbox, Valkey wakes, durable retry/quarantine, and `recoverFromPostgres`. Cycle: load snapshots → pure decision → persist assignment → authoritative approval/policy → acquire by IDs/locks → increment fence → lease → ack/heartbeat/checkpoint → submit to review or durable retry/quarantine. No completion/certification/merge method exists.
## 7. Exact Gateway/DTO freeze for KBN-105
### 7.1 Common wire rules
Base is `/api/v1/workspaces/:workspaceId`. Mutations require header `Idempotency-Key` (1128 chars). Existing-aggregate mutations also require `If-Match-Version` (positive integer); create and privileged assignment-cycle requests are the only exceptions, while proposal submission carries `expectedTargetVersion` in its body. Body workspace fields are forbidden. Tenant denial follows one 404/403 policy without foreign existence detail.
```ts
interface SuccessEnvelopeV1<T> {
contractVersion: '1.0.0';
data: T;
aggregateRevision: string;
correlationId: string;
}
interface ListEnvelopeV1<T> extends SuccessEnvelopeV1<T[]> {
page: { cursor: string | null; nextCursor: string | null; limit: number };
}
```
Errors are the exact health/transport/version unions in §2 plus validation/auth/not-found. Public DTOs never expose/accept internal write proof.
### 7.2 Exact route registry
| Method/path | Request body/query | Success data |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| `GET /kanban-health` | none | `KanbanHealthResponseV1` |
| `GET /projects` | `status,ownerUserId,ownerTeamId,cursor,limit` | project list |
| `POST /projects` | `name,key,description,status,priority,ownerUserId XOR ownerTeamId,metadata` | project |
| `GET /projects/:projectId` | none | project |
| `PATCH /projects/:projectId` | editable create fields + expected header | project |
| `POST /projects/:projectId/archive` | `reason` | project |
| `GET /tasks` | `projectId,missionId,milestoneId,status,priority,ownerUserId,ownerTeamId,specialistRole,tag,dueState,archived,cursor,limit` | task summary list |
| `POST /tasks` | `projectId,missionId?,milestoneId?,parentTaskId?,title,description?,acceptanceCriteria[],status,priority,rank,ownerUserId XOR ownerTeamId,specialistRole?,dueAt?,notBeforeAt?,estimateMinutes?,retryPolicy?,tagIds[],metadata` | task detail |
| `GET /tasks/:taskId` | none | task detail including readiness/dependencies/assignment/lease/events |
| `PATCH /tasks/:taskId` | editable non-transition fields | task detail |
| `POST /tasks/:taskId/transition` | `toStatus,reason?` | task detail |
| `POST /tasks/:taskId/move` | `toStatus?,beforeTaskId?,afterTaskId?` | task detail with persisted rank |
| `POST /tasks/:taskId/archive` | `reason` | task detail |
| `PUT /tasks/:taskId/tags` | `tagIds[]` | task detail |
| `POST /tasks/:taskId/dependencies` | `predecessorTaskId,type` | dependency |
| `DELETE /tasks/:taskId/dependencies/:predecessorTaskId` | no body | deleted dependency ID |
| `GET /tasks/:taskId/events` | `cursor,limit` | event list |
| `GET /tags` | `query,cursor,limit` | tag list |
| `POST /tags` | `name,color?` | tag |
| `GET /change-proposals` | `state,targetType,targetId,cursor,limit` | proposal list |
| `POST /change-proposals` | `sourceNoteDigest,targetType,targetId,expectedTargetVersion,commandType,commandPayload` | inert proposal |
| `GET /change-proposals/:proposalId` | none | proposal |
| `POST /change-proposals/:proposalId/accept` | `reason` | proposal + normal command result |
| `POST /change-proposals/:proposalId/reject` | `reason` | proposal |
| `GET /coordinator/eligibility` | `projectId?,missionId?,cursor,limit` | `EligibilityDecisionV1[]` |
| `POST /coordinator/assignment-cycles` | `limit` | assignment proposals; privileged internal |
| `POST /coordinator/assignments/:assignmentId/approve` | `decision,reason,policyRevision,artifactIds[]` | approval decision |
| `POST /coordinator/leases/acquire` | `taskId,assignmentId,approvalDecisionId,targetSessionId,leaseTtlSeconds` | lease with decimal-string fence |
| `POST /coordinator/leases/:leaseId/ack` | `taskId,sessionId,fencingToken` | lease |
| `POST /coordinator/leases/:leaseId/heartbeat` | `taskId,sessionId,fencingToken,extendSeconds` | lease |
| `POST /coordinator/leases/:leaseId/checkpoints` | `taskId,sessionId,fencingToken,sequence,resumableSummary,artifactIds[],contextUsagePercent` | checkpoint |
| `POST /coordinator/leases/:leaseId/submit-review` | `taskId,sessionId,fencingToken,artifactIds[],summary` | task in `in_review` |
All Coordinator mutations except human approval are service-identity-only. Generic task PATCH cannot perform claim/heartbeat/checkpoint/review/certification/completion shortcuts. Completion after certification uses a separately gated lifecycle command owned by the Portfolio/Sub-Orchestrator flow, not the Coordinator.
### 7.3 DTO invariants
Task summary/detail use exact schema vocabularies, owner union, `version: number`, `fencingCounter: string`, explicit `archivedAt/by/reason`, normalized tags, computed readiness, and separate assignment/lease. Assignment DTO includes one persisted ID, task/version, exact principal/agent/session, role, state, expiry, policy, proposer/reason. Lease/checkpoint DTOs serialize every fence as decimal string. Proposal DTO exposes no hidden write authority.
### 7.4 MCP ownership and mapping
coder3 exclusively owns:
- `apps/gateway/src/mcp/mcp.dto.ts`
- `mcp.controller.ts`
- `mcp.service.ts`
- `mcp.module.ts`
- `mcp.tokens.ts`
- `mcp.service.spec.ts`
MCP tools are thin maps: `mosaic_projects_{list,get,create,update,archive}`, `mosaic_tasks_{list,get,create,update,transition,move,archive,set_tags,add_dependency,remove_dependency}`, and `mosaic_change_proposals_{list,get,submit,accept,reject}` to the exact routes above. coder4 owns CLI/projection clients only and must not edit Gateway MCP files.
KBN-105 publishes route+DTO fixture digest before KBN-110/120/130. Every web/CLI/MCP call must match this registry and the generated client.
## 8. Recovery contract and bounded delivery slice
Runtime must invoke normative `validateRecoveryPostureV1`; JSON Schema alone is insufficient. It rejects unknown fields, PITR/WAL mismatch, RPO better than mechanism, unsafe storage, and weakened High-assurance. High-assurance is RPO 15m/RTO 4h, WAL ≤5m, PITR ≥35d, base ≤24h, restore test ≤30d, break-glass ≤90d, encrypted separate-failure-domain storage.
KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recovery runbook. It wires parser/refinement, override audit, mechanism assertions, restore test, and break-glass evidence. Any deployment manifest is separately enumerated and Mos-serialized. Recovery config has no SOT/gate/Coordinator authority fields.
## 9. Integration, security, and hold
Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
The build hold remains active until independent re-review reports GO for KCR-001016. Mos alone releases waves and serializes integration roots.

View File

@@ -1,261 +0,0 @@
# Native Kanban/SOT P0P3 — Dependency-Ordered Build Slices
**Status:** CANON INDEPENDENTLY APPROVED; PUBLICATION IN PROGRESS
**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes
**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files
**Implementation hold:** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete.
> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported.
## Execution invariants
- PostgreSQL is the sole writable SOT; current-main Drizzle is the persistence foundation.
- Mutations require fresh internal PostgreSQL transaction-local write proof and fail closed otherwise.
- Public health DTOs, Valkey, files, browser state, providers, and outage notes cannot authorize writes.
- Outage notes return only through attributable `change_proposals`; proposal acceptance executes the normal command.
- Mechanical Coordinator is non-LLM and cannot invent scope, waive gates, certify, or merge.
- Certifier is final independent gate with no merge authority.
- Workspace is the hard tenant. Project hierarchy is project-congruent. Assignment, approval, lease, fence, checkpoint, and evidence are relationally bound.
- Recovery tiers change recovery posture only.
## 1. Collision-free ownership
| USC lane | Exclusive ownership | Must not edit |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| **coder2 — schema/recovery** | `packages/db/src/schema.ts`; `packages/db/drizzle/**`; DB tests; `packages/config/src/recovery-posture.ts`; `packages/config/src/recovery-posture.spec.ts`; `docs/runbooks/kanban-postgres-recovery.md` | Gateway, Brain repositories, Coordinator, web, CLI/importer |
| **coder3 — domain/Gateway/MCP server** | Kanban repositories under `packages/brain/src/`; Gateway workspace/project/mission/milestone/task/kanban/health/coord modules; **exact MCP files:** `apps/gateway/src/mcp/mcp.dto.ts`, `mcp.controller.ts`, `mcp.service.ts`, `mcp.module.ts`, `mcp.tokens.ts`, `mcp.service.spec.ts`; Gateway root wiring/tests | DB schema/migrations, `packages/coord`, web, CLI/importer |
| **coder4 — CLI → pure Coordinator → migration tooling** | In this one fixed lane order: KBN-120 (`packages/mosaic` CLI/projection) → KBN-200 (`packages/coord/src/mechanical/**`) → KBN-300/320 (`scripts/kanban-migration/**`) | DB, Gateway/MCP server, web |
| **coder5 — web** | `apps/web/src/app/(dashboard)/{tasks,projects}/**`; `apps/web/src/components/{tasks,projects}/**`; Kanban web API/types; later Coordinator/migration-review routes | DB, Gateway, Coordinator, CLI/importer |
| **Mos — publication/integration** | Contract amendments, exact endpoint registry publication, serialized root exports/manifests/lockfiles, integration gates | Active lane feature files |
Shared roots, package exports/manifests, lockfiles, and generated artifacts are integration-serialized. Contract changes stop affected lanes and require Mos approval.
## 2. Parallelization legend
- **SERIAL:** prerequisite must be complete and reviewed.
- **PARALLEL-GROUP:** disjoint files and exact frozen contract permit concurrent work.
- **LANE-SERIAL:** one lane's stated order cannot change.
- **INTEGRATION-SERIAL:** component heads green first; semantic findings return to owner.
## 3. Corrected dependency graph
```text
KBN-000 canon remediation
-> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE)
-> KBN-100 schema + concrete N-1 migration implementation
├─ KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL)
│ ├─ KBN-110 domain + Gateway + MCP server implementation
│ ├─ KBN-120 CLI/projection implementation [coder4 first]
│ └─ KBN-130 web MVP implementation
└─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
KBN-110 + KBN-120 + KBN-130 + KBN-115
-> KBN-140 P1 integration/SIT
-> KBN-200 pure decision engine [coder4 after KBN-120]
-> KBN-210 persistence/service adapter + approval/lease binding
-> KBN-220 Coordinator operations UI
-> KBN-230 P2 concurrency/fault/gate integration
KBN-230
-> KBN-300 importer dry-run/apply/verify [coder4 after KBN-200]
├─ KBN-310 migration reviewer UI
└─ KBN-320 cutover/rollback tooling [coder4 after KBN-300]
KBN-310 + KBN-320
-> KBN-330 rehearsal/reconciliation
-> KBN-340 owner-gated cutover/stabilization
```
No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320.
## 4. P0 — Canon, threat gate, schema, and exact API freeze
### KBN-000 — Remediate and publish canon
- **Owner:** Mos / publication control plane.
- **Mode:** SERIAL; publication gate in progress.
- **IN:** Resolve KCR-001016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review.
- **OUT:** Feature implementation.
- **Depends on:** none.
- **Contract surfaces:** all canon.
- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO.
### KBN-010 — Threat, authorization, and constraint-impact gate
- **Owner:** coder3; independent `secrev`.
- **Mode:** SERIAL prerequisite of KBN-100.
- **Exclusive files:** Mos-selected threat/auth docs only.
- **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage.
- **OUT:** Runtime/schema edits.
- **Depends on:** KBN-000 independent re-review GO.
- **Contract surfaces:** schema constraints, health proof, exact errors, command-family authorization.
- **Evidence:** signed constraint-impact matrix; no unresolved schema-impact finding; SecReview pass.
### KBN-100 — Unified Drizzle schema and concrete N-1 migration
- **Owner:** **coder2**.
- **Mode:** SERIAL.
- **Exclusive files:** `packages/db/src/schema.ts`, `packages/db/drizzle/**`, DB tests.
- **IN:** All frozen tables/joins/enums; workspace/project-congruent constraints; owners/principals; tags/archive; change proposals with both workspace-aware task-event composite FKs and frozen event-before-proposal DDL order; assignment approvals; durable execution/quarantine; monotonic bigint fence; exact checkpoint/evidence joins; RESTRICT/immutability; concrete current-main expand/backfill/switch/contract map.
- **OUT:** Repositories, Gateway, Coordinator behavior, UI, importer.
- **Depends on:** **KBN-010 completed**.
- **Contract surfaces:** `kanban-schema.v1.ts`; SHARED-CONTRACT current-main delta map.
- **Evidence:** reviewed SQL; empty/prod-shape/partial-resume/rollback tests; N-1 app safety; legacy columns remain declared; workspace/project mismatch negatives; proposal event-FK missing/foreign-workspace tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; SecReview.
### KBN-105 — Exact Gateway/MCP endpoint, DTO, and error freeze
- **Owner:** Mos + coder3 contract author; independent endpoint-alignment reviewer.
- **Mode:** SERIAL after KBN-100; prerequisite for KBN-110/120/130.
- **Exclusive files:** canonical endpoint-registry/DTO contract docs; no implementation.
- **IN:** Exact routes and methods from SHARED-CONTRACT §8; request/success/error fields; status codes; pagination/filter/revision envelopes; idempotency/expected-version headers/fields; proposal commands; health proof exclusion from public DTOs; MCP tool-to-route map.
- **OUT:** Controller/service/client implementation.
- **Depends on:** KBN-100.
- **Contract surfaces:** health/error unions; schema IDs/statuses; Gateway DTO freeze.
- **Evidence:** every FE/CLI/MCP call maps 1:1 to a route; 503/502-504/409 non-cross-map fixtures; contract digest published.
### KBN-115 — Recovery posture parser, mechanisms, and evidence
- **Owner:** **coder2**, lane-serial after KBN-100.
- **Mode:** PARALLEL with KBN-110/120/130 after KBN-105.
- **Exclusive files:** `packages/config/src/recovery-posture.ts`, `.spec.ts`, `docs/runbooks/kanban-postgres-recovery.md`; deployment-specific backup manifest changes are a separately enumerated Mos integration patch.
- **IN:** Wire normative `validateRecoveryPostureV1`; override audit; backup/WAL/PITR mechanism assertions; off-cluster encryption/failure-domain checks; restore and break-glass evidence procedure.
- **OUT:** SOT/gate/Coordinator policy knobs; DB business schema.
- **Depends on:** KBN-100, KBN-105.
- **Contract surfaces:** `recovery-posture.v1.ts` only.
- **Evidence:** impossible-combination tests; High-assurance weakening tests; selected-tier mechanism verification; restore and break-glass evidence; SecReview.
## 5. P1 — Thin native MVP
### KBN-110 — Workspace-safe domain, Gateway, MCP server, and proposal commands
- **Owner:** **coder3**.
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
- **Exclusive files:** ownership map, including all exact MCP server files listed there.
- **IN:** Workspace-safe repositories; project/task/dependency/tag/archive CRUD; transitions; exact owners; assignment/approval/link/artifact queries; submit/query/accept/reject change proposals; health endpoint; internal write-proof mint/revalidation; event/outbox atomicity; frozen DTOs/routes.
- **OUT:** Scheduling algorithm, web, CLI, DB schema.
- **Depends on:** KBN-100, KBN-105.
- **Contract surfaces:** all four TypeScript contracts and exact registry.
- **Evidence:** DTO/service/controller/integration tests; active-membership and no-oracle negatives; proposal cannot mutate directly; submission event is the new proposal's exact `change_proposal.submitted` event; acceptance links the executed normal command for the locked proposal and same workspace/target; missing, foreign-workspace, unrelated-proposal/target/command event negatives; exact failure mapping; endpoint registry; SecReview.
### KBN-120 — CLI, MCP client mapping, and generated projection
- **Owner:** **coder4**; first coder4 slice.
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
- **Exclusive files:** `packages/mosaic/src/commands/{kanban,tasks,projects}.ts`; `packages/mosaic/src/projections/**`; tests. **No `apps/gateway/src/mcp/**` edits.\*\*
- **IN:** Frozen query/mutation routes; proposal commands; compact context; generated `TASKS.md`; deliberate denial/transport/conflict handling.
- **OUT:** Gateway/MCP server, file importer, raw SQL/Valkey, Coordinator.
- **Depends on:** KBN-105; runtime integration later requires KBN-110.
- **Evidence:** contract fixtures; same revision; no import parser; same idempotency key on transport retry; 503 never auto-retried.
### KBN-130 — Writable Kanban/List and minimal Projects UI
- **Owner:** **coder5**.
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
- **Exclusive files:** web ownership map.
- **IN:** Workspace context; projects; tasks; tags; explicit archive; detail; accessible move/reorder; filters; dependency/readiness; owner/assignment/lease; audit; proposal visibility; conflict/loading/error/reconnect.
- **OUT:** Gateway/schema, Coordinator operations UI, migration UI.
- **Depends on:** KBN-105; runtime integration later requires KBN-110.
- **Evidence:** frozen contract mocks; real-Gateway journeys; keyboard/non-drag; tags/archive semantics; no-oracle tenant negatives; 503/transport/409 distinct UI.
### KBN-140 — P1 integration and situational gate
- **Owner:** Mos integration; independent reviewer/SecReview/Certifier.
- **Mode:** INTEGRATION-SERIAL.
- **IN:** KBN-110/120/130/115; unavoidable root exports only.
- **OUT:** P2 behavior.
- **Depends on:** KBN-110, KBN-120, KBN-130, KBN-115.
- **Evidence:** clean migration; web/CLI/MCP/projection revision parity; forged/expired health negatives; change-proposal event-chain success plus missing/foreign/unrelated-event negatives; tag/archive; tenant negatives; endpoint registry; author-independent review; Certifier pass.
## 6. P2 — Mechanical Coordinator
### KBN-200 — Pure deterministic decision engine
- **Owner:** **coder4**; second coder4 slice, strictly after KBN-120.
- **Mode:** SERIAL in coder4 lane.
- **Exclusive files:** `packages/coord/src/mechanical/**` and pure tests.
- **IN:** `MechanicalCoordinatorDecisionEngineV1`; complete immutable snapshots; eligibility/explanation; fairness/order; capability matching; expiry/retry/quarantine decisions.
- **OUT:** ID loading, PostgreSQL, Drizzle, Gateway, Valkey, health-proof minting, persistence, `recoverFromPostgres`, LLM calls.
- **Depends on:** KBN-140 (or Mos may release after KBN-120 + frozen types if no P1 semantic risk remains).
- **Evidence:** deterministic/property tests; snapshot completeness; no I/O/model imports; no authority methods.
### KBN-210 — Coordinator persistence/service adapter and approval-bound leases
- **Owner:** **coder3**.
- **Mode:** SERIAL after KBN-200.
- **Exclusive files:** Gateway `coord` and repositories.
- **IN:** `MechanicalCoordinatorServicePortV1`; snapshot loading; proposal persistence; manual/versioned policy approval; acquire by IDs; reload+lock task/assignment/approval/session; fresh txn-local write proof; atomic task fence increment; lease/ack/heartbeat/checkpoint/submit; durable retry/quarantine; outbox/Valkey wake; restart recovery.
- **OUT:** Pure algorithm, UI, DB schema.
- **Depends on:** KBN-110, KBN-200.
- **Evidence:** forged/stale approval rejection; target/session/version/expiry/policy checks; concurrent monotonic fences; same-workspace mismatch negatives; bigint precision; stale worker rejection; DB/Valkey faults; SecReview.
### KBN-220 — Coordinator operations UI
- **Owner:** **coder5**.
- **Mode:** after KBN-210 exact DTO freeze.
- **IN:** Roster; eligibility; persisted assignment state; approvals/overrides; exact lease/fence; durable retry/quarantine; role/gate/Certifier visibility.
- **OUT:** Scheduling decisions, schema, merge control for Certifier.
- **Depends on:** KBN-210.
- **Evidence:** authorized journeys; reason required; stale refresh; no Certifier merge; endpoint alignment/accessibility.
### KBN-230 — P2 concurrency/fault/gate integration
- **Owner:** Mos integration; independent reviewer/SecReview/Certifier.
- **Mode:** INTEGRATION-SERIAL.
- **Depends on:** KBN-200, KBN-210, KBN-220.
- **Evidence:** one lease; monotonic fences; exact relational mismatches rejected; expired proof; forged healthy; approval binding; restart; durable quarantine; outbox recovery; author≠reviewer; Certifier final/no merge.
## 7. P3 — Shadow migration and cutover
### KBN-300 — One-way importer dry-run/apply/verify
- **Owner:** **coder4**; third coder4 slice.
- **Mode:** after KBN-230.
- **Exclusive files:** `scripts/kanban-migration/import/**`.
- **IN:** Immutable jarvis-brain/Vikunja snapshots; deterministic mapping; source digest/lineage; Gateway writes; rejects; no dispatch.
- **OUT:** Bidirectional sync, direct DB/file canonical writes, unrelated brain data.
- **Depends on:** KBN-230.
- **Evidence:** idempotency; counts/fields; malformed/foreign rejects; no dispatch; SecReview.
### KBN-310 — Shadow reviewer UI
- **Owner:** **coder5**.
- **Mode:** PARALLEL-GROUP P3-A after KBN-300 report freeze.
- **IN:** Read-only counts/diffs/rejects/lineage/sign-off.
- **OUT:** Apply/cutover mutations.
- **Depends on:** KBN-300.
- **Evidence:** read-only and tenant tests; pagination/accessibility.
### KBN-320 — Cutover/rollback tooling
- **Owner:** **coder4**; fourth coder4 slice, after KBN-300.
- **Mode:** PARALLEL-GROUP P3-A with KBN-310.
- **Exclusive files:** `scripts/kanban-migration/cutover/**`.
- **IN:** Freeze assertion; backup/checksum; final delta; client switch; legacy writer/credential shutdown; rollback delta; stabilization.
- **OUT:** Destructive deletion, reverse sync, ungated production execution.
- **Depends on:** KBN-300.
- **Evidence:** fail-safe rehearsal; no dual writer; rollback authority; SecReview.
### KBN-330 — Migration rehearsal/reconciliation
- **Owner:** Mos + coder4 support + independent data reviewer.
- **Mode:** INTEGRATION-SERIAL.
- **Depends on:** KBN-310, KBN-320.
- **Evidence:** signed exceptions; selected-tier restore; backlog hold; no legacy changes; Certifier readiness.
### KBN-340 — Final cutover/stabilization
- **Owner:** Mos/control plane; owner-gated operation.
- **Mode:** SERIAL.
- **Depends on:** KBN-330 PASS and Jason authorization.
- **Evidence:** no legacy writer; scoped Gateway identities; no accidental dispatch; terminal green health/CI; Certifier evidence; owner retirement approval.
## 8. Consistent USC wave schedule
| Wave | coder2 | coder3 | coder4 | coder5 |
| ---- | ------------------------- | -------------------------------------- | ------------------------------ | ------------------------------ |
| 0 | Wait | **KBN-010** | Wait | Wait |
| 1 | **KBN-100** | Review constraint implementation | Wait | Wait |
| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 |
| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 |
| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** |
| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** |
Mos alone releases slices and lifts the build hold after independent re-review GO.

View File

@@ -1,206 +0,0 @@
/**
* Mosaic Native Kanban — frozen health/error contract v1.
* Publication contract only; no runtime implementation is included here.
*
* PostgreSQL is the sole writable SOT. Public health DTOs are observations,
* never write authority. Only an internal transaction-local proof produced by
* the PostgreSQL adapter may authorize a mutation.
*/
export const KANBAN_CONTRACT_VERSION = '1.0.0' as const;
export const kanbanHealthStates = ['healthy', 'read-only-degraded', 'write-unavailable'] as const;
export type KanbanHealthState = (typeof kanbanHealthStates)[number];
interface KanbanHealthBaseV1 {
contractVersion: typeof KANBAN_CONTRACT_VERSION;
checkedAt: string;
/** Observation expires at this RFC 3339 instant; it still never authorizes writes. */
validUntil: string;
policyRevision: string;
reasons: string[];
}
export interface HealthyKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
state: 'healthy';
readHealthProven: true;
writeHealthProven: true;
}
export interface ReadOnlyDegradedKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
state: 'read-only-degraded';
readHealthProven: true;
writeHealthProven: false;
}
export interface WriteUnavailableKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
state: 'write-unavailable';
readHealthProven: false;
writeHealthProven: false;
}
/** Public, discriminated observation. Contradictory combinations are unrepresentable. */
export type KanbanHealthResponseV1 =
| HealthyKanbanHealthResponseV1
| ReadOnlyDegradedKanbanHealthResponseV1
| WriteUnavailableKanbanHealthResponseV1;
/** Pure evaluation context. It cannot authorize a mutation. */
export interface KanbanEvaluationContextV1 {
contractVersion: typeof KANBAN_CONTRACT_VERSION;
workspaceId: string;
correlationId: string;
now: string;
policyRevision: string;
observedHealth: KanbanHealthResponseV1;
}
/**
* Non-exported brand: public DTO deserialization cannot construct this type.
* The PostgreSQL adapter mints it only after a fresh write probe inside the same
* transaction and validates checkedAt <= now < validUntil and policy revision.
*/
declare const postgresWriteHealthProofBrand: unique symbol;
export interface PostgresWriteHealthProofV1 {
readonly [postgresWriteHealthProofBrand]: true;
readonly source: 'postgres-transaction-local-write-probe';
readonly transactionId: string;
readonly checkedAt: string;
readonly validUntil: string;
readonly policyRevision: string;
}
/** Internal mutation context; MUST NOT appear in REST/MCP/CLI request DTOs. */
export interface InternalKanbanMutationContextV1 {
contractVersion: typeof KANBAN_CONTRACT_VERSION;
workspaceId: string;
correlationId: string;
causationId?: string;
idempotencyKey: string;
now: string;
expectedPolicyRevision: string;
writeProof: PostgresWriteHealthProofV1;
}
interface MutationFailureBaseV1 {
contractVersion: typeof KANBAN_CONTRACT_VERSION;
retryable: false;
requestOutcome: 'not_applied';
idempotencyKey: string;
correlationId: string;
message: string;
}
/** KCR-016: code/state pairing is exact and cannot cross-map. */
export interface ReadOnlyWriteHealthDenialV1 extends MutationFailureBaseV1 {
kind: 'deliberate_fail_closed_denial';
code: 'KANBAN_WRITE_HEALTH_UNPROVEN';
healthState: 'read-only-degraded';
checkedAt: string;
}
export interface WriteUnavailableDenialV1 extends MutationFailureBaseV1 {
kind: 'deliberate_fail_closed_denial';
code: 'KANBAN_WRITE_UNAVAILABLE';
healthState: 'write-unavailable';
checkedAt: string;
}
export type DeliberateWriteDenialV1 = ReadOnlyWriteHealthDenialV1 | WriteUnavailableDenialV1;
export const transportErrorCodes = [
'GATEWAY_UNREACHABLE',
'GATEWAY_TIMEOUT',
'UPSTREAM_BAD_GATEWAY',
] as const;
export type TransportErrorCode = (typeof transportErrorCodes)[number];
/** Client-normalized transport uncertainty; never an authoritative 503 body. */
export interface RetryableTransportErrorV1 {
contractVersion: typeof KANBAN_CONTRACT_VERSION;
kind: 'retryable_transport_error';
code: TransportErrorCode;
retryable: true;
requestOutcome: 'unknown';
/** Retry MUST reuse this exact key. */
idempotencyKey: string;
correlationId: string;
message: string;
}
export interface VersionConflictV1 {
contractVersion: typeof KANBAN_CONTRACT_VERSION;
kind: 'version_conflict';
code: 'AGGREGATE_VERSION_CONFLICT';
retryable: false;
requestOutcome: 'not_applied';
aggregateType: 'project' | 'mission' | 'milestone' | 'task' | 'change_proposal';
aggregateId: string;
expectedVersion: number;
actualVersion: number;
idempotencyKey: string;
correlationId: string;
message: string;
}
export type KanbanMutationFailureV1 =
| DeliberateWriteDenialV1
| RetryableTransportErrorV1
| VersionConflictV1;
export const kanbanHealthCapabilities: Readonly<
Record<KanbanHealthState, { canonicalReads: boolean; mutations: boolean }>
> = {
healthy: { canonicalReads: true, mutations: true },
'read-only-degraded': { canonicalReads: true, mutations: false },
'write-unavailable': { canonicalReads: false, mutations: false },
};
/** Exact HTTP/error normalization freeze; 503, transport, and 409 cannot cross-map. */
export const kanbanFailureHttpMapV1 = {
KANBAN_WRITE_HEALTH_UNPROVEN: {
httpStatus: 503,
kind: 'deliberate_fail_closed_denial',
requestOutcome: 'not_applied',
retryable: false,
},
KANBAN_WRITE_UNAVAILABLE: {
httpStatus: 503,
kind: 'deliberate_fail_closed_denial',
requestOutcome: 'not_applied',
retryable: false,
},
AGGREGATE_VERSION_CONFLICT: {
httpStatus: 409,
kind: 'version_conflict',
requestOutcome: 'not_applied',
retryable: false,
},
GATEWAY_UNREACHABLE: {
httpStatus: 502,
kind: 'retryable_transport_error',
requestOutcome: 'unknown',
retryable: true,
},
GATEWAY_TIMEOUT: {
httpStatus: 504,
kind: 'retryable_transport_error',
requestOutcome: 'unknown',
retryable: true,
},
UPSTREAM_BAD_GATEWAY: {
httpStatus: 502,
kind: 'retryable_transport_error',
requestOutcome: 'unknown',
retryable: true,
},
} as const;
/**
* Required negative contract tests:
* - contradictory state/proof booleans fail type/schema validation;
* - expired internal proof and policy mismatch deny before mutation;
* - Valkey-only liveness cannot mint PostgresWriteHealthProofV1;
* - public/caller-forged `healthy` cannot enter InternalKanbanMutationContextV1;
* - authoritative 503, transport 502/504/timeout, and 409 mappings are exhaustive.
*/

File diff suppressed because it is too large Load Diff

View File

@@ -1,419 +0,0 @@
/**
* Mosaic Native Kanban — frozen Mechanical Coordinator contracts v1.
*
* The pure decision engine and persistence/orchestration service are separate.
* Neither surface can create scope, edit acceptance, waive gates, certify,
* merge, release a deployment, or close a provider issue.
*/
import type {
DeliberateWriteDenialV1,
InternalKanbanMutationContextV1,
KanbanEvaluationContextV1,
KanbanMutationFailureV1,
RetryableTransportErrorV1,
VersionConflictV1,
} from './health-state.v1.js';
export const COORDINATOR_CONTRACT_VERSION = '1.0.0' as const;
export type Uuid = string;
export type IsoTimestamp = string;
/** PostgreSQL bigint-safe decimal string; never a JavaScript number. */
export type FencingTokenV1 = string;
export const specialistRoles = [
'planning',
'enhance',
'coder',
'review',
'security-review',
'pr-monitor',
'certifier',
] as const;
export type SpecialistRole = (typeof specialistRoles)[number];
/** One vocabulary shared with task_assignment_state_v1 in the Drizzle schema. */
export const assignmentStates = [
'awaiting_approval',
'policy_pre_authorized',
'approved',
'rejected',
'leased',
'released',
'expired',
'superseded',
] as const;
export type AssignmentStateV1 = (typeof assignmentStates)[number];
export const readinessStates = [
'dependency-gated',
'schedule-gated',
'policy-gated',
'lease-available',
'leased',
'retry-delayed',
'exhausted',
'quarantined',
] as const;
export type ReadinessState = (typeof readinessStates)[number];
export interface RetryStateSnapshotV1 {
disposition: 'available' | 'retry_delayed' | 'quarantined' | 'exhausted';
attemptCount: number;
maxAttempts: number;
nextEligibleAt: IsoTimestamp | null;
idempotent: boolean;
terminalReason: string | null;
version: number;
}
export interface TaskEligibilitySnapshotV1 {
workspaceId: Uuid;
taskId: Uuid;
taskVersion: number;
projectId: Uuid;
projectActive: boolean;
missionId: Uuid | null;
missionActive: boolean;
status: 'ready';
priority: 'critical' | 'high' | 'medium' | 'low';
boardRank: string;
dueAt: IsoTimestamp | null;
notBeforeAt: IsoTimestamp | null;
createdAt: IsoTimestamp;
requiredRole: SpecialistRole;
requiredCapabilities: readonly string[];
blockingDependencies: readonly {
taskId: Uuid;
done: boolean;
completionConditionSatisfied: boolean;
}[];
releaseApproval: {
decisionId: Uuid;
approved: boolean;
policyRevision: string;
} | null;
activeLeaseId: Uuid | null;
retry: RetryStateSnapshotV1;
}
export interface AgentSessionSnapshotV1 {
workspaceId: Uuid;
agentId: Uuid;
sessionId: Uuid;
state: 'available' | 'busy';
roles: readonly SpecialistRole[];
capabilities: readonly string[];
capacity: number;
activeLeaseCount: number;
heartbeatAt: IsoTimestamp;
}
export interface EligibilityExplanationV1 {
taskId: Uuid;
eligible: boolean;
readiness: ReadinessState;
reasons: readonly {
gate:
| 'status'
| 'project'
| 'mission'
| 'dependency'
| 'schedule'
| 'retry'
| 'approval'
| 'lease'
| 'capability'
| 'capacity'
| 'health';
satisfied: boolean;
code: string;
detail: string;
}[];
policyRevision: string;
evaluatedAt: IsoTimestamp;
}
export interface AssignmentProposalDecisionV1 {
workspaceId: Uuid;
taskId: Uuid;
taskVersion: number;
targetAgentId: Uuid;
targetSessionId: Uuid;
specialistRole: SpecialistRole;
initialState: 'awaiting_approval' | 'policy_pre_authorized';
policyRevision: string;
explanation: EligibilityExplanationV1;
expiresAt: IsoTimestamp;
}
export interface AssignmentCycleSnapshotV1 {
context: KanbanEvaluationContextV1;
tasks: readonly TaskEligibilitySnapshotV1[];
sessions: readonly AgentSessionSnapshotV1[];
workspaceFairness: Readonly<Record<Uuid, number>>;
limit: number;
}
export interface AssignmentCycleDecisionV1 {
evaluatedTaskCount: number;
proposals: readonly AssignmentProposalDecisionV1[];
explanations: readonly EligibilityExplanationV1[];
}
export interface LeaseExpirySnapshotV1 {
workspaceId: Uuid;
taskId: Uuid;
taskVersion: number;
leaseId: Uuid;
assignmentId: Uuid;
sessionId: Uuid;
fencingToken: FencingTokenV1;
state: 'pending_ack' | 'active';
acknowledgeBy: IsoTimestamp;
expiresAt: IsoTimestamp;
lastHeartbeatAt: IsoTimestamp | null;
retry: RetryStateSnapshotV1;
}
export interface LeaseExpiryDecisionV1 {
leaseId: Uuid;
action: 'retain' | 'release' | 'retry' | 'quarantine' | 'exhaust';
reason: string;
nextEligibleAt: IsoTimestamp | null;
}
/** Pure package owned by KBN-200. It receives complete immutable snapshots. */
export interface MechanicalCoordinatorDecisionEngineV1 {
evaluateAssignmentCycle(snapshot: AssignmentCycleSnapshotV1): AssignmentCycleDecisionV1;
explainEligibility(
context: KanbanEvaluationContextV1,
task: TaskEligibilitySnapshotV1,
sessions: readonly AgentSessionSnapshotV1[],
): EligibilityExplanationV1;
decideLeaseExpiry(
context: KanbanEvaluationContextV1,
lease: LeaseExpirySnapshotV1,
): LeaseExpiryDecisionV1;
}
export interface PersistedAssignmentV1 {
assignmentId: Uuid;
workspaceId: Uuid;
taskId: Uuid;
taskVersion: number;
targetAgentId: Uuid;
targetSessionId: Uuid;
specialistRole: SpecialistRole;
state: AssignmentStateV1;
policyRevision: string;
proposedBy: { kind: 'user' | 'agent'; id: Uuid };
reason: string;
createdAt: IsoTimestamp;
expiresAt: IsoTimestamp;
}
export interface TaskLeaseV1 {
leaseId: Uuid;
workspaceId: Uuid;
taskId: Uuid;
taskVersion: number;
assignmentId: Uuid;
agentId: Uuid;
sessionId: Uuid;
state: 'pending_ack' | 'active';
fencingToken: FencingTokenV1;
attempt: number;
acquiredAt: IsoTimestamp;
acknowledgeBy: IsoTimestamp;
lastHeartbeatAt: IsoTimestamp | null;
expiresAt: IsoTimestamp;
}
interface ServiceCommandBaseV1 {
context: InternalKanbanMutationContextV1;
taskId: Uuid;
expectedTaskVersion: number;
}
export interface AcquireApprovedLeaseCommandV1 extends ServiceCommandBaseV1 {
assignmentId: Uuid;
approvalDecisionId: Uuid;
targetSessionId: Uuid;
leaseTtlSeconds: number;
}
export interface LeaseCommandV1 extends ServiceCommandBaseV1 {
leaseId: Uuid;
sessionId: Uuid;
fencingToken: FencingTokenV1;
}
export interface HeartbeatLeaseCommandV1 extends LeaseCommandV1 {
extendSeconds: number;
}
export interface CheckpointCommandV1 extends LeaseCommandV1 {
sequence: number;
resumableSummary: string;
artifactIds: readonly Uuid[];
contextUsagePercent: number;
}
export interface SubmitForReviewCommandV1 extends LeaseCommandV1 {
artifactIds: readonly Uuid[];
summary: string;
}
export interface ReleaseLeaseCommandV1 extends LeaseCommandV1 {
reason:
| 'worker_requested'
| 'ack_timeout'
| 'heartbeat_timeout'
| 'task_submitted'
| 'policy_revoked'
| 'shutdown';
}
export interface AssignmentCycleCommandV1 {
context: InternalKanbanMutationContextV1;
limit: number;
}
export interface ExpirySweepCommandV1 {
context: InternalKanbanMutationContextV1;
limit: number;
}
export interface RecoverCoordinatorCommandV1 {
context: InternalKanbanMutationContextV1;
}
interface CoordinatorRejectionBaseV1 {
kind: 'coordinator_rejection';
retryable: false;
requestOutcome: 'not_applied';
correlationId: Uuid;
idempotencyKey: string;
message: string;
}
export type CoordinatorPolicyRejectionV1 =
| (CoordinatorRejectionBaseV1 & { code: 'WORKSPACE_MISMATCH' })
| (CoordinatorRejectionBaseV1 & {
code: 'TASK_NOT_ELIGIBLE';
explanation: EligibilityExplanationV1;
})
| (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_REQUIRED' })
| (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_STALE' })
| (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_STALE' })
| (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_TARGET_MISMATCH' })
| (CoordinatorRejectionBaseV1 & { code: 'POLICY_REVISION_MISMATCH' })
| (CoordinatorRejectionBaseV1 & { code: 'ARTIFACT_WORKSPACE_MISMATCH' })
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_ALREADY_ACTIVE' })
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_FOUND' })
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_ACTIVE' })
| (CoordinatorRejectionBaseV1 & {
code: 'ACK_DEADLINE_EXPIRED';
expiredAt: IsoTimestamp;
})
| (CoordinatorRejectionBaseV1 & {
code: 'FENCING_TOKEN_STALE';
currentFencingToken: FencingTokenV1;
})
| (CoordinatorRejectionBaseV1 & { code: 'SESSION_MISMATCH' })
| (CoordinatorRejectionBaseV1 & {
code: 'HEARTBEAT_EXPIRED';
expiredAt: IsoTimestamp;
})
| (CoordinatorRejectionBaseV1 & {
code: 'CHECKPOINT_SEQUENCE_CONFLICT';
currentSequence: number;
})
| (CoordinatorRejectionBaseV1 & { code: 'RETRY_EXHAUSTED' })
| (CoordinatorRejectionBaseV1 & {
code: 'NON_IDEMPOTENT_RETRY_REQUIRES_ORCHESTRATOR';
});
/** Explicit mapping to the Gateway mutation failure union; no arbitrary booleans. */
export type CoordinatorFailureV1 =
| DeliberateWriteDenialV1
| VersionConflictV1
| RetryableTransportErrorV1
| CoordinatorPolicyRejectionV1;
export interface CoordinatorSuccessV1<T> {
ok: true;
value: T;
correlationId: Uuid;
}
export interface CoordinatorFailureResultV1 {
ok: false;
failure: CoordinatorFailureV1;
}
export type CoordinatorResultV1<T> = CoordinatorSuccessV1<T> | CoordinatorFailureResultV1;
export interface ExpirySweepResultV1 {
examined: number;
released: readonly Uuid[];
retryScheduled: readonly Uuid[];
quarantined: readonly Uuid[];
exhausted: readonly Uuid[];
}
export interface RestartRecoveryResultV1 {
activeLeaseIds: readonly Uuid[];
expiredLeaseIds: readonly Uuid[];
pendingAssignmentIds: readonly Uuid[];
pendingOutboxEventIds: readonly Uuid[];
}
/** Persistence/Gateway adapter owned by KBN-210. */
export interface MechanicalCoordinatorServicePortV1 {
/** Loads immutable snapshots, invokes pure engine, and persists proposals atomically. */
runAssignmentCycle(
command: AssignmentCycleCommandV1,
): Promise<CoordinatorResultV1<{ assignments: readonly PersistedAssignmentV1[] }>>;
/** Query path loads by ID; public health observation cannot authorize mutation. */
getEligibilityExplanation(
context: KanbanEvaluationContextV1,
taskId: Uuid,
): Promise<CoordinatorResultV1<EligibilityExplanationV1>>;
/**
* Accepts IDs only. Implementation reloads and locks assignment + approval +
* task + target session in PostgreSQL, then verifies workspace, task version,
* target agent/session, state, expiry, policy revision, and current approval.
*/
acquireApprovedLease(
command: AcquireApprovedLeaseCommandV1,
): Promise<CoordinatorResultV1<TaskLeaseV1>>;
acknowledgeLease(command: LeaseCommandV1): Promise<CoordinatorResultV1<TaskLeaseV1>>;
heartbeatLease(command: HeartbeatLeaseCommandV1): Promise<CoordinatorResultV1<TaskLeaseV1>>;
appendCheckpoint(
command: CheckpointCommandV1,
): Promise<CoordinatorResultV1<{ checkpointId: Uuid }>>;
submitForReview(
command: SubmitForReviewCommandV1,
): Promise<CoordinatorResultV1<{ taskVersion: number; status: 'in_review' }>>;
releaseLease(command: ReleaseLeaseCommandV1): Promise<CoordinatorResultV1<{ released: true }>>;
expireAndRecover(
command: ExpirySweepCommandV1,
): Promise<CoordinatorResultV1<ExpirySweepResultV1>>;
recoverFromPostgres(
command: RecoverCoordinatorCommandV1,
): Promise<CoordinatorResultV1<RestartRecoveryResultV1>>;
}
/** Compile-time mapping guarantee: Coordinator Gateway failures are Kanban failures or exact policy rejections. */
export function isKanbanMutationFailureV1(
failure: CoordinatorFailureV1,
): failure is KanbanMutationFailureV1 {
return (
failure.kind === 'deliberate_fail_closed_denial' ||
failure.kind === 'retryable_transport_error' ||
failure.kind === 'version_conflict'
);
}

View File

@@ -1,369 +0,0 @@
/**
* Mosaic Native Kanban — frozen recovery-posture contract v1.
* Recovery posture is configurable; SOT, write-health, Coordinator authority,
* and gate semantics are not fields and cannot be overridden.
*/
export const RECOVERY_POSTURE_CONTRACT_VERSION = '1.0.0' as const;
export const recoveryTiers = ['lite', 'standard', 'high-assurance'] as const;
export type RecoveryTier = (typeof recoveryTiers)[number];
export interface OffClusterStorageV1 {
required: true;
encrypted: true;
separateFailureDomain: true;
minimumCopies: number;
storageClass: 'encrypted-object-storage' | 'encrypted-backup-target';
}
export interface RecoveryPostureV1 {
contractVersion: typeof RECOVERY_POSTURE_CONTRACT_VERSION;
tier: RecoveryTier;
targetRpoMinutes: number;
targetRtoMinutes: number;
baseBackupIntervalHours: number;
/** null means WAL archival/PITR is disabled. */
walArchiveIntervalMinutes: number | null;
/** 0 means PITR is disabled. */
pitrRetentionDays: number;
restoreTestIntervalDays: number;
breakGlassDrillIntervalDays: number;
offClusterStorage: OffClusterStorageV1;
}
export const recoveryPostureDefaults: Readonly<Record<RecoveryTier, RecoveryPostureV1>> = {
lite: {
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
tier: 'lite',
targetRpoMinutes: 24 * 60,
targetRtoMinutes: 24 * 60,
baseBackupIntervalHours: 24,
walArchiveIntervalMinutes: null,
pitrRetentionDays: 0,
restoreTestIntervalDays: 90,
breakGlassDrillIntervalDays: 365,
offClusterStorage: {
required: true,
encrypted: true,
separateFailureDomain: true,
minimumCopies: 1,
storageClass: 'encrypted-backup-target',
},
},
standard: {
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
tier: 'standard',
targetRpoMinutes: 60,
targetRtoMinutes: 8 * 60,
baseBackupIntervalHours: 24,
walArchiveIntervalMinutes: 15,
pitrRetentionDays: 14,
restoreTestIntervalDays: 90,
breakGlassDrillIntervalDays: 180,
offClusterStorage: {
required: true,
encrypted: true,
separateFailureDomain: true,
minimumCopies: 1,
storageClass: 'encrypted-object-storage',
},
},
'high-assurance': {
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
tier: 'high-assurance',
targetRpoMinutes: 15,
targetRtoMinutes: 4 * 60,
baseBackupIntervalHours: 24,
walArchiveIntervalMinutes: 5,
pitrRetentionDays: 35,
restoreTestIntervalDays: 30,
breakGlassDrillIntervalDays: 90,
offClusterStorage: {
required: true,
encrypted: true,
separateFailureDomain: true,
minimumCopies: 1,
storageClass: 'encrypted-object-storage',
},
},
};
/** Shape schema. Normative cross-field semantics are enforced by validateRecoveryPostureV1. */
export const recoveryPostureJsonSchemaV1 = {
$id: 'https://mosaicstack.dev/contracts/recovery-posture.v1.schema.json',
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
additionalProperties: false,
required: [
'contractVersion',
'tier',
'targetRpoMinutes',
'targetRtoMinutes',
'baseBackupIntervalHours',
'walArchiveIntervalMinutes',
'pitrRetentionDays',
'restoreTestIntervalDays',
'breakGlassDrillIntervalDays',
'offClusterStorage',
],
properties: {
contractVersion: { const: RECOVERY_POSTURE_CONTRACT_VERSION },
tier: { enum: recoveryTiers },
targetRpoMinutes: { type: 'integer', minimum: 1 },
targetRtoMinutes: { type: 'integer', minimum: 1 },
baseBackupIntervalHours: { type: 'integer', minimum: 1 },
walArchiveIntervalMinutes: {
anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }],
},
pitrRetentionDays: { type: 'integer', minimum: 0 },
restoreTestIntervalDays: { type: 'integer', minimum: 1 },
breakGlassDrillIntervalDays: { type: 'integer', minimum: 1 },
offClusterStorage: {
type: 'object',
additionalProperties: false,
required: ['required', 'encrypted', 'separateFailureDomain', 'minimumCopies', 'storageClass'],
properties: {
required: { const: true },
encrypted: { const: true },
separateFailureDomain: { const: true },
minimumCopies: { type: 'integer', minimum: 1 },
storageClass: {
enum: ['encrypted-object-storage', 'encrypted-backup-target'],
},
},
},
},
} as const;
export const recoveryValidationCodes = [
'INVALID_SHAPE',
'UNKNOWN_FIELD',
'PITR_REQUIRES_WAL',
'WAL_REQUIRES_PITR',
'RPO_BETTER_THAN_MECHANISM',
'OFF_CLUSTER_REQUIRED',
'HIGH_ASSURANCE_WEAKENED',
] as const;
export type RecoveryValidationCode = (typeof recoveryValidationCodes)[number];
export interface RecoveryValidationIssueV1 {
code: RecoveryValidationCode;
path: string;
message: string;
}
export type RecoveryValidationResultV1 =
| { ok: true; value: RecoveryPostureV1 }
| { ok: false; issues: RecoveryValidationIssueV1[] };
const topLevelFields = new Set([
'contractVersion',
'tier',
'targetRpoMinutes',
'targetRtoMinutes',
'baseBackupIntervalHours',
'walArchiveIntervalMinutes',
'pitrRetentionDays',
'restoreTestIntervalDays',
'breakGlassDrillIntervalDays',
'offClusterStorage',
]);
const storageFields = new Set([
'required',
'encrypted',
'separateFailureDomain',
'minimumCopies',
'storageClass',
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isPositiveInteger(value: unknown): value is number {
return Number.isInteger(value) && Number(value) > 0;
}
function isNonnegativeInteger(value: unknown): value is number {
return Number.isInteger(value) && Number(value) >= 0;
}
/**
* Normative parser/refinement. Deployment code MUST call this function (or a
* byte-for-byte behaviorally equivalent generated validator), not JSON Schema
* shape validation alone.
*/
export function validateRecoveryPostureV1(input: unknown): RecoveryValidationResultV1 {
const issues: RecoveryValidationIssueV1[] = [];
if (!isRecord(input)) {
return {
ok: false,
issues: [{ code: 'INVALID_SHAPE', path: '$', message: 'posture must be an object' }],
};
}
for (const key of Object.keys(input)) {
if (!topLevelFields.has(key)) {
issues.push({ code: 'UNKNOWN_FIELD', path: `$.${key}`, message: 'unknown field' });
}
}
const tier = input['tier'];
const storage = input['offClusterStorage'];
const integerFields = [
'targetRpoMinutes',
'targetRtoMinutes',
'baseBackupIntervalHours',
'restoreTestIntervalDays',
'breakGlassDrillIntervalDays',
] as const;
if (input['contractVersion'] !== RECOVERY_POSTURE_CONTRACT_VERSION) {
issues.push({
code: 'INVALID_SHAPE',
path: '$.contractVersion',
message: `must equal ${RECOVERY_POSTURE_CONTRACT_VERSION}`,
});
}
if (!recoveryTiers.includes(tier as RecoveryTier)) {
issues.push({ code: 'INVALID_SHAPE', path: '$.tier', message: 'unknown recovery tier' });
}
for (const field of integerFields) {
if (!isPositiveInteger(input[field])) {
issues.push({
code: 'INVALID_SHAPE',
path: `$.${field}`,
message: 'must be a positive integer',
});
}
}
if (!isNonnegativeInteger(input['pitrRetentionDays'])) {
issues.push({
code: 'INVALID_SHAPE',
path: '$.pitrRetentionDays',
message: 'must be a nonnegative integer',
});
}
if (
input['walArchiveIntervalMinutes'] !== null &&
!isPositiveInteger(input['walArchiveIntervalMinutes'])
) {
issues.push({
code: 'INVALID_SHAPE',
path: '$.walArchiveIntervalMinutes',
message: 'must be null or a positive integer',
});
}
if (!isRecord(storage)) {
issues.push({
code: 'INVALID_SHAPE',
path: '$.offClusterStorage',
message: 'must be an object',
});
} else {
for (const key of Object.keys(storage)) {
if (!storageFields.has(key)) {
issues.push({
code: 'UNKNOWN_FIELD',
path: `$.offClusterStorage.${key}`,
message: 'unknown field',
});
}
}
if (
storage['required'] !== true ||
storage['encrypted'] !== true ||
storage['separateFailureDomain'] !== true
) {
issues.push({
code: 'OFF_CLUSTER_REQUIRED',
path: '$.offClusterStorage',
message: 'storage must be required, encrypted, and in a separate failure domain',
});
}
if (!isPositiveInteger(storage['minimumCopies'])) {
issues.push({
code: 'INVALID_SHAPE',
path: '$.offClusterStorage.minimumCopies',
message: 'must be a positive integer',
});
}
if (
storage['storageClass'] !== 'encrypted-object-storage' &&
storage['storageClass'] !== 'encrypted-backup-target'
) {
issues.push({
code: 'INVALID_SHAPE',
path: '$.offClusterStorage.storageClass',
message: 'unsupported storage class',
});
}
}
const wal = input['walArchiveIntervalMinutes'];
const pitr = input['pitrRetentionDays'];
if (pitr !== 0 && wal === null) {
issues.push({
code: 'PITR_REQUIRES_WAL',
path: '$.pitrRetentionDays',
message: 'PITR retention requires WAL archival',
});
}
if (wal !== null && pitr === 0) {
issues.push({
code: 'WAL_REQUIRES_PITR',
path: '$.walArchiveIntervalMinutes',
message: 'WAL archival requires positive PITR retention',
});
}
if (
isPositiveInteger(input['targetRpoMinutes']) &&
isPositiveInteger(input['baseBackupIntervalHours']) &&
(wal === null || isPositiveInteger(wal))
) {
const mechanismMinutes = wal === null ? input['baseBackupIntervalHours'] * 60 : wal;
if (mechanismMinutes > input['targetRpoMinutes']) {
issues.push({
code: 'RPO_BETTER_THAN_MECHANISM',
path: '$.targetRpoMinutes',
message: `configured mechanism can only support ${mechanismMinutes} minutes`,
});
}
}
if (tier === 'high-assurance') {
const weakened =
!isPositiveInteger(input['targetRpoMinutes']) ||
input['targetRpoMinutes'] > 15 ||
!isPositiveInteger(input['targetRtoMinutes']) ||
input['targetRtoMinutes'] > 4 * 60 ||
!isPositiveInteger(input['baseBackupIntervalHours']) ||
input['baseBackupIntervalHours'] > 24 ||
!isPositiveInteger(wal) ||
wal > 5 ||
!isNonnegativeInteger(pitr) ||
pitr < 35 ||
!isPositiveInteger(input['restoreTestIntervalDays']) ||
input['restoreTestIntervalDays'] > 30 ||
!isPositiveInteger(input['breakGlassDrillIntervalDays']) ||
input['breakGlassDrillIntervalDays'] > 90;
if (weakened) {
issues.push({
code: 'HIGH_ASSURANCE_WEAKENED',
path: '$',
message: 'high-assurance posture may be strengthened but not weakened',
});
}
}
if (issues.length > 0) return { ok: false, issues };
return { ok: true, value: input as unknown as RecoveryPostureV1 };
}
export interface RecoveryPostureOverrideAuditV1 {
actorId: string;
reason: string;
effectiveAt: string;
policyRevision: string;
previous: RecoveryPostureV1;
next: RecoveryPostureV1;
}

View File

@@ -1,16 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"incremental": false,
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"baseUrl": ".",
"paths": {
"drizzle-orm": ["../../packages/db/node_modules/drizzle-orm/index.d.ts"],
"drizzle-orm/pg-core": ["../../packages/db/node_modules/drizzle-orm/pg-core/index.d.ts"]
}
},
"include": ["contracts/*.ts"]
}

View File

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

View File

@@ -1,59 +0,0 @@
VERDICT: GO
# Native Kanban/SOT canon independent re-review 2
Independent read-only re-review of the complete updated staged canon. Prior proposal-audit blocker is closed; no KCR-001016 regression or new blocker found.
## Prior blocker closure
- `contracts/kanban-schema.v1.ts:836-837` declares the required unique `task_events(workspace_id,id)` key before proposal declaration.
- `contracts/kanban-schema.v1.ts:885-894` adds both composite proposal audit FKs—submission and accepted-command event—to that exact workspace-aware key with `RESTRICT`.
- Declaration/migration order is executable and explicit in `SHARED-CONTRACT.md:79-91`: events/key first, proposal table second, both FKs third/fourth, then command enablement. This avoids forward-reference/circular-DDL ambiguity.
- Submission/acceptance semantics are frozen in `SHARED-CONTRACT.md:87-91`: preallocate proposal ID; create exact `change_proposal.submitted` event and proposal in one transaction; on acceptance lock proposal/target, execute the normal command, and bind only a same-workspace/target event with submission causation and `payload.changeProposalId` equal to the locked proposal.
- Required missing, foreign-workspace, unrelated-proposal, unrelated-target, and unrelated-command negatives are explicit in `REQUIREMENTS.md` REQ-SOT-004 and `SHARED-CONTRACT.md:121`; KBN-100/110/140 own migration, service, and integration evidence.
## KCR closure matrix
| KCR | Status |
| ------------------------------------------------------ | ------ |
| 001 health/proof | CLOSED |
| 002 error discrimination | CLOSED |
| 003 approval/assignment binding | CLOSED |
| 004 monotonic fencing/composites | CLOSED |
| 005 tenant-safe relations | CLOSED |
| 006 outage proposal persistence/commands/audit binding | CLOSED |
| 007 dependency/API freeze sequencing | CLOSED |
| 008 concrete N-1 map | CLOSED |
| 009 dependency uniqueness | CLOSED |
| 010 project congruence | CLOSED |
| 011 immutable audit retention | CLOSED |
| 012 retry/quarantine/vocabulary | CLOSED |
| 013 archive/tags target semantics | CLOSED |
| 014 recovery validator/owner slice | CLOSED |
| 015 pure Coordinator split | CLOSED |
| 016 health code/state pairing | CLOSED |
Fixed invariants remain consistent: PostgreSQL is sole writable SOT; writes require transaction-local proof and fail closed; exports never import sources; notes are attributable proposals only; Coordinator has no scope/gate/certify/merge authority; Certifier has no merge authority.
## Reproducible validation evidence
Executed read-only with current-stack config/toolchain `/src/mosaic-mono-v1`:
```text
./node_modules/.bin/prettier --config /src/mosaic-mono-v1/.prettierrc --check <all 9 publication artifacts>
PASS: All matched files use Prettier code style.
strict TypeScript --noEmit --strict --skipLibCheck --target ES2022 --module NodeNext --moduleResolution NodeNext <four contract copies with current Drizzle node_modules resolution>
PASS
cascade/TODO/TBD/stale-hold grep plus composite-FK/semantic-marker invariant checks
PASS
```
The TypeScript check used a disposable copy under `/home/hermes/agent-work` solely to provide external-file NodeNext dependency resolution; the reviewed staging artifacts were not modified.
## Residual findings
None blocking. Implementation must execute the frozen KBN-100/KBN-110/KBN-140 proposal-event-chain tests and SecReview evidence before feature release, as already required by the canon.
No artifact source repository, branch, PR, or provider state was modified.

View File

@@ -1,357 +0,0 @@
# Independent Review — Native Kanban/SOT Canon
**Reviewer:** `enhance-sol` (independent of author `planner-sol`)
**Date:** 2026-07-13
**Review mode:** design/contract only; read-only against the staged canon
**Source plan:** `/home/hermes/agent-work/planning/mosaic-native-kanban-sot-plan.md` (`sha256:96ea4fb91436ec9a53f371d27276e27f62ecf817662599ff9152df0db55296e5`)
**Canon reviewed:** every listed artifact under `/home/hermes/agent-work/planning/kanban-canon/`, including the four TypeScript contracts; the author scratchpad was also read as validation context.
## Executive verdict
# NO-GO
The canon is not freeze-ready. I found **8 BLOCKERs**, **7 MAJORs**, and **1 MINOR**. The prose preserves the ratified authority model well, but the frozen types/schema leave concrete fail-closed, approval, fencing, tenant, outage-proposal, migration, and parallelization gaps. Those gaps would force implementation lanes either to invent contract semantics or to ship paths that violate fixed invariants.
### Blocking findings
1. Health/write authorization can be represented as contradictory, stale, or caller-asserted state.
2. Coordinator failures collapse authoritative denial, unknown transport outcome, and version conflict into one permissive shape.
3. Assignment proposals and approval proofs have no authoritative relational binding; lease acquisition accepts a forgeable proof DTO.
4. Fencing uniqueness is present, but monotonic fencing and same-task lease/checkpoint binding are not.
5. Workspace-safe accountable-owner, assignment-principal, and evidence/artifact relationships are not frozen.
6. Attributable post-recovery proposals have neither a canonical table nor command contract.
7. The slice graph starts schema/UI work before prerequisite threat and exact API/DTO freezes and contradicts coder4 lane order.
8. P0 claims a migration map while publishing only generic rules; the concrete N-1 transition from current `origin/main` is absent.
---
## Findings
### KCR-001 — BLOCKER — “Healthy” is not a proof and can be contradictory or stale
**Location**
- `contracts/health-state.v1.ts:21-31``KanbanHealthResponseV1` permits every combination of `state`, `readHealthProven`, and `writeHealthProven`.
- `contracts/mechanical-coordinator.v1.ts:40-49``CoordinatorContextV1` accepts a caller-supplied `healthState` enum only.
- `contracts/mechanical-coordinator.v1.ts:255-293` — every Coordinator operation, including mutating operations, accepts that context.
- `SHARED-CONTRACT.md:171-184` — mutations are allowed only after live PostgreSQL read/write probes.
**Violation**
Fixed invariant 2 / `REQ-SOT-002`: mutations must fail closed unless write health is positively proven. The current type permits `{ state: 'healthy', writeHealthProven: false }`, and the Coordinator mutation boundary can be invoked with a stale or fabricated `{ healthState: 'healthy' }`. A Valkey/client-derived enum could therefore be mistaken for write authorization.
**Minimal fix**
1. Make `KanbanHealthResponseV1` a discriminated union with only these legal combinations: `healthy => read=true/write=true`, `read-only-degraded => read=true/write=false`, and `write-unavailable => read=false/write=false`.
2. Do not accept write authority from a public DTO. Require Gateway/domain code to obtain and revalidate a fresh internal PostgreSQL write-health proof at mutation time (including `checkedAt`, bounded validity/policy revision, and transaction-local enforcement).
3. Split pure evaluation context from mutation context; mutation methods must accept only an unforgeable/internal healthy context or perform the probe themselves.
4. Add negative contract tests for contradictory state, expired proof, Valkey-only liveness, and caller-forged `healthy`.
### KCR-002 — BLOCKER — Coordinator error shape can conflate denial, unknown outcome, and conflict
**Location**
- `contracts/mechanical-coordinator.v1.ts:184-216` — one `CoordinatorFailureV1` allows every code to pair with arbitrary `retryable` and either `requestOutcome` value.
- `contracts/health-state.v1.ts:51-106` — the Gateway health contract correctly distinguishes deliberate denial, transport uncertainty, and version conflict.
- `SHARED-CONTRACT.md:177-216` — frozen client semantics require those cases not to be conflated.
**Violation**
Charter E and `REQ-SOT-002`. The current Coordinator result can legally encode `WRITE_HEALTH_UNPROVEN` as `retryable: true, requestOutcome: 'unknown'`, or `VERSION_CONFLICT` as retryable. That permits blind retry or a false “unknown” outcome after an authoritative fail-closed denial.
**Minimal fix**
Replace `CoordinatorFailureV1` with a discriminated union keyed by code/kind:
- deliberate health denial: `not_applied`, `retryable:false`;
- version conflict: `not_applied`, `retryable:false`, current version;
- stale fence/session/eligibility/approval failures: exact non-retry semantics;
- transport failure: a separate `retryable_transport_error`, `unknown`, same idempotency key.
Reuse or map explicitly to `KanbanMutationFailureV1`, and add exhaustive client tests proving 503 authoritative bodies, 502/504/timeouts, and 409 cannot cross-map.
### KCR-003 — BLOCKER — Approval proof is forgeable and is not linked to the persisted proposal
**Location**
- `contracts/mechanical-coordinator.v1.ts:107-137` — proposal and approval DTOs.
- `contracts/mechanical-coordinator.v1.ts:265-270``acquireApprovedLease` accepts the entire `ApprovalProofV1` by value.
- `contracts/kanban-schema.v1.ts:650-688``task_assignments` has no proposal expiry, task version, session binding, or proposal/approval FK.
- `contracts/kanban-schema.v1.ts:823-863``approval_decisions` can target only a task or mission and has no proposal/assignment relation.
- `SHARED-CONTRACT.md:128-137` — lease acquisition requires authoritative approval under the exact policy revision.
**Violation**
Fixed invariant 5 and `REQ-COORD-002/003`. A caller can construct an `ApprovalProofV1`; the schema cannot prove that it belongs to the proposal, workspace, task version, agent/session, unexpired policy revision, or still-current approval. The DTO state vocabulary (`awaiting_approval | policy_pre_authorized`) also does not map directly to the persisted assignment states (`proposed | approved | ...`).
**Minimal fix**
Persist one authoritative proposal/assignment identity with task version, target agent/session, expiry, state, and policy revision. Add a workspace-aware approval relation to that identity. Change lease acquisition to accept IDs, then reload and lock proposal + approval + task inside PostgreSQL and verify workspace, current version, target session, state, expiry, and policy revision before creating the lease. Freeze one state vocabulary across schema and DTOs.
### KCR-004 — BLOCKER — Fencing is unique but not monotonically increasing; relational binding is incomplete
**Location**
- `contracts/kanban-schema.v1.ts:694-743``task_leases` has positive/unique fencing tokens but no monotonic per-task counter.
- `contracts/kanban-schema.v1.ts:748-784` — checkpoints independently carry task, lease, and fencing token.
- `contracts/kanban-schema.v1.ts:905-910` — token equality is deferred to prose; same-task lease binding is not stated.
- `contracts/mechanical-coordinator.v1.ts:140-175` — worker commands depend on fencing safety.
**Violation**
Fixed invariant 12 / `REQ-COORD-003`. Uniqueness permits token 10 followed by token 9. A lease can reference assignment A while naming task B in the same workspace, and a checkpoint can reference lease A while naming task B. `bigint(..., { mode: 'number' })` also eventually loses integer precision in JavaScript.
**Minimal fix**
Add a durable per-task fencing counter (or equivalent PostgreSQL sequence row) incremented atomically under task lock and use the returned value for every new lease. Add workspace-aware composite constraints tying lease to its exact task+assignment and checkpoint to exact task+lease+fence. Use bigint-safe representation (`bigint`/serialized decimal), and test monotonicity, concurrent claims, stale lower tokens, and mismatched same-workspace IDs.
### KCR-005 — BLOCKER — Hard tenant boundary is not frozen for several polymorphic relationships
**Location**
- `contracts/kanban-schema.v1.ts:315-318` and `475-478` — project/task accountable owners are unvalidated `(kind, text id)` pairs.
- `contracts/kanban-schema.v1.ts:659-663` — assignment principals are unvalidated `(kind, text id)` pairs.
- `contracts/kanban-schema.v1.ts:758` and `841` — checkpoint/evidence artifact relationships are JSON arrays without workspace-aware FKs.
- `SHARED-CONTRACT.md:89-96` — only selected polymorphic checks are delegated to domain transactions; owner/principal/evidence checks are not included.
- `REQUIREMENTS.md:101-108` — every relationship must reject cross-workspace IDs.
**Violation**
Fixed invariant 7 / `REQ-TEN-001` and `REQ-ID-001`. The frozen schema can name a team or agent from another workspace as owner/assignee, and can embed foreign-workspace artifact IDs in checkpoint or approval evidence arrays. A global user ID is also insufficient without active workspace membership validation.
**Minimal fix**
Use workspace-aware owner/assignment join tables or separate nullable user/team/agent columns with exactly-one checks and composite FKs where possible. Model checkpoint/evidence artifact links as workspace-scoped join rows, or freeze explicit transaction checks for every ID. Require active workspace membership for user principals and workspace-agent/session consistency for agent principals. Add DB/repository/API/Coordinator cross-workspace negative tests without existence oracles.
### KCR-006 — BLOCKER — Post-recovery outage proposals have no canonical persistence or command surface
**Location**
- `REQUIREMENTS.md:93-99` — proposal submission and authorized accept/reject are required.
- `SHARED-CONTRACT.md:26-29` — outage notes may return only as authenticated proposals.
- `SHARED-CONTRACT.md:243-267` — the thin command/query contract contains no proposal submit/get/accept/reject operations.
- `contracts/kanban-schema.v1.ts:1-916` — no proposal table captures proposed command, target/version, attribution, lifecycle, or decision.
- `TASKS.md:99-108` — KBN-110 does not own an outage-proposal command path.
**Violation**
Fixed invariant 4 / `REQ-SOT-004`. An implementation lane would have to invent storage or misuse artifacts/approval gates. Either path risks silently applying an outage note or creating shadow state.
**Minimal fix**
Add a workspace-scoped `change_proposals`/`outage_proposals` contract with authenticated proposer, source note digest, target aggregate, expected version, proposed typed command/payload, pending/accepted/rejected state, decision actor/reason/time, idempotency key, and audit linkage. Add explicit submit/query/accept/reject Gateway commands. Acceptance must execute the normal command in a healthy transaction; a proposal itself can never claim, order, satisfy a gate, or mutate the target.
### KCR-007 — BLOCKER — Parallel slice ordering is not freeze-safe and contains a direct lane-order contradiction
**Location**
- `TASKS.md:43-60` — dependency graph makes KBN-010 and KBN-100 siblings.
- `TASKS.md:88-97` — KBN-100 nevertheless depends on KBN-010 threat findings that alter constraints.
- `SHARED-CONTRACT.md:243` and `INDEX.md:44-50` — exact route names/DTO placement remain unresolved.
- `TASKS.md:110-130` — KBN-120/130 depend on a frozen endpoint/DTO contract, while mocks may begin before KBN-110 lands.
- `TASKS.md:145-153` — KBN-200 says lane-serial after KBN-120.
- `TASKS.md:248-254` — wave table runs KBN-200 before KBN-120.
**Violation**
Charter C and the mandatory freeze-before-parallelize gate. Schema can begin before tenant/threat findings are complete; web/CLI consumers have only semantic operations, not exact DTO/endpoint contracts; coder4 has two opposite legal orders. This does not create same-file edits immediately, but it guarantees contract invention or rework across active lanes.
**Minimal fix**
1. Make KBN-010 (or an explicit constraint-impact gate from it) a completed prerequisite of KBN-100.
2. Add a small serialized KBN-105 endpoint/DTO/endpoint-registry freeze, with exact request/response/error DTOs, before KBN-120 and KBN-130 implementation.
3. Choose one coder4 lane order and use it consistently in slice text, graph, and wave table.
4. Name the exact MCP-owned files or assign their Gateway changes to coder3 before coder4 starts.
### KCR-008 — BLOCKER — Claimed P0 migration map is absent; concrete N-1 hazards remain unresolved
**Location**
- `MISSION-MANIFEST.md:153-157` — P0 says to publish a migration map and states the build hold is lifted at line 3.
- `SHARED-CONTRACT.md:101-121` — only generic expand/backfill/contract rules are supplied.
- `contracts/kanban-schema.v1.ts:1-916` — target-state declarations reuse live table names and make target fields required.
- Current foundation evidence: `origin/main:packages/db/src/schema.ts:120-301` has no workspace keys, nullable project/mission links, legacy text status vocabularies, `tasks.tags`, `tasks.assignee`, `tasks.due_date`, mission JSON milestones/config, `mission_tasks.status`, and legacy agent fields.
**Violation**
Charter D / `REQ-MIG-001` and the P0 exit claim. The generic rule is correct, but coder2 lacks the required field-by-field transition map. A direct Drizzle reconciliation could attempt type narrowing/status conversion, add required workspace/project/owner columns too early, or drop legacy columns before N-1 readers and writers are retired.
**Minimal fix**
Publish a concrete current-main delta map before lifting the hold. For each existing table/column, specify expand, backfill, compatibility read/write, switch, and contract release. At minimum cover:
- nullable-first `workspace_id`, required project/owner fields, and workspace backfill;
- legacy task/project/mission status aliases or shadow columns before v1 emission;
- `mission_tasks.status` read retirement and write-source prohibition;
- mapping/retention for tags, assignee, due date, mission description/config/milestones, and agent fields;
- current milestone circular FK ordering;
- empty, production-shape, partial-resume, and rollback/downgrade tests already named in §4.
Explicitly require legacy columns to remain in the unified Drizzle declaration during the expand/N-1 window.
### KCR-009 — MAJOR — Dependency uniqueness permits parallel duplicate edges
**Location**
- `contracts/kanban-schema.v1.ts:561-567` — unique key includes `dependencyType`.
- `SHARED-CONTRACT.md:47-49` — calls for a unique directed edge.
- `REQUIREMENTS.md:142-149` — duplicate edge attempts must fail.
**Violation**
`REQ-DEP-001`. The same predecessor/successor pair can be inserted three times, once per dependency type. That is not a unique directed edge and complicates readiness semantics.
**Minimal fix**
Make `(workspace_id, predecessor_task_id, successor_task_id)` unique independent of type, or explicitly redefine the requirement as one edge per type and freeze deterministic multi-edge completion semantics. The source plan says unique directed edge, so the former is the minimal faithful fix.
### KCR-010 — MAJOR — Same-workspace planning relationships can contradict the project hierarchy
**Location**
- `contracts/kanban-schema.v1.ts:325``projects.currentMilestoneId` has no FK in the declaration.
- `contracts/kanban-schema.v1.ts:427-448` — mission/milestone association checks workspace but not common project.
- `contracts/kanban-schema.v1.ts:490-516` — a tasks project, mission, milestone, and parent only need share a workspace, not a project.
- `contracts/kanban-schema.v1.ts:905-908` — only current milestone is mentioned as a deferred invariant.
**Violation**
`REQ-PLAN-001` and schema correctness. A task in project A can point to a mission/milestone/parent task from project B in the same workspace. A mission can associate a milestone from another project despite having one required project.
**Minimal fix**
Add project-congruent composite keys/FKs (or freeze mandatory transaction checks) for task→mission, task→milestone, task→parent, mission→milestone, and project→current milestone. Add same-workspace/same-project negative tests.
### KCR-011 — MAJOR — Immutable/append-only records can be erased by parent cascades
**Location**
- `contracts/kanban-schema.v1.ts:798-818``task_events` is described as append-only but remains under a workspace cascade.
- `contracts/kanban-schema.v1.ts:911` — only application-role UPDATE/DELETE privilege removal is stated.
- Numerous canonical relationships use `onDelete('cascade')`, including workspace roots and artifact/checkpoint/event owners.
- `REQUIREMENTS.md:41-43` and `154-170` — audit must be append-only, attributable, and reconstructable.
**Violation**
`REQ-AUD-001`. Revoking direct DELETE on `task_events` does not prevent a parent delete from cascading into the audit log. Checkpoints and immutable artifacts also lack explicit append-only privilege/retention semantics.
**Minimal fix**
Use lifecycle/archive states and `RESTRICT` for canonical parent deletion during normal operation. Freeze a separate, audited retention/break-glass purge procedure. Apply INSERT/SELECT-only or equivalent immutability controls to task events, checkpoints, and immutable artifacts, and test that parent deletion cannot silently erase them.
### KCR-012 — MAJOR — Coordinator persistence lacks durable quarantine/retry state and DTO/schema alignment
**Location**
- `contracts/mechanical-coordinator.v1.ts:239-244` — expiry returns `quarantined` IDs.
- `contracts/kanban-schema.v1.ts:457-490` — task has only untyped `retryPolicy` metadata and no quarantine/execution disposition.
- `contracts/mechanical-coordinator.v1.ts:173``evidenceIds` has no corresponding evidence table/type; schema has artifacts.
- `contracts/kanban-schema.v1.ts:479`, `663`, and agent role JSON — specialist roles are free text despite the frozen role vocabulary in `mechanical-coordinator.v1.ts:19-29`.
**Violation**
`REQ-COORD-004` and internal consistency. PostgreSQL cannot deterministically reconstruct why/when a task was quarantined, its bounded retry state, or which typed evidence was submitted. Free-text roles allow the schema and engine to disagree.
**Minimal fix**
Freeze a durable execution/retry/quarantine record (attempt count, next eligibility, terminal reason, actor/policy, timestamps, version) or typed task columns with events. Align `evidenceIds` to artifact IDs or add a real evidence entity. Use one specialist-role enum/check across tasks, assignments, agents/sessions, DTOs, and Coordinator.
### KCR-013 — MAJOR — Thin MVP promises task archive and tag filtering without target-state semantics
**Location**
- `REQUIREMENTS.md:182-193` — users must archive tasks and filter by tags.
- `SHARED-CONTRACT.md:252-267` — mutations include cancel but not archive task.
- `contracts/kanban-schema.v1.ts:457-490` — no task archive field and no typed tags field/table.
- Current `origin/main` already has `tasks.tags`, making omission from the target declaration a migration-loss hazard.
**Violation**
`REQ-UI-001/002` and internal acceptance consistency. “Archive” cannot be implemented without inventing whether it means cancelled, hidden, or soft-deleted; tag filtering has no frozen storage/query contract.
**Minimal fix**
Either remove task archive/tag acceptance from P1, or add explicit non-lifecycle archival semantics (`archived_at/by/reason`) and a workspace-safe tags model/query contract. Preserve/migrate the current tags column until the selected model is live.
### KCR-014 — MAJOR — Recovery contract states critical rules only in comments and has no owning implementation slice
**Location**
- `contracts/recovery-posture.v1.ts:97-147` — exported JSON Schema validates only local field shapes.
- `contracts/recovery-posture.v1.ts:150-156` — PITR/WAL, effective RPO, off-cluster, high-assurance minima, and audit rules are comments only.
- `REQUIREMENTS.md:270-277` — parser rejection of impossible combinations is acceptance-critical.
- `TASKS.md:75-244` — no bounded slice owns recovery config parsing, override audit, backup/WAL setup, or restore/break-glass evidence.
**Violation**
`REQ-REC-001`. A consumer using the advertised JSON Schema can accept weakened high-assurance values, PITR without WAL, or an impossible RPO. The task plan has no lane accountable for closing that acceptance criterion.
**Minimal fix**
Export a normative `validateRecoveryPostureV1`/schema refinement with machine-testable cross-field checks and add a bounded Infra/recovery slice (serialized if it touches shared config) owning config parsing, override audit, mechanism verification, restore test, and break-glass evidence. Recovery config must continue to expose no authority/gate knobs.
### KCR-015 — MAJOR — Pure Coordinator slice cannot implement two frozen methods without persistence access
**Location**
- `contracts/mechanical-coordinator.v1.ts:259-263``explainEligibility` receives only `taskId`, not a structured snapshot.
- `contracts/mechanical-coordinator.v1.ts:289-293``recoverFromPostgres` explicitly reads PostgreSQL.
- `TASKS.md:145-153` — KBN-200 is a pure engine with no SQL, Drizzle, Gateway, or Valkey.
- `TASKS.md:157-164` — persistence belongs to coder3/KBN-210.
**Violation**
Charter C and internal consistency. coder4 cannot implement the frozen port in a pure package without crossing coder3s persistence boundary. If coder3 implements the port instead, KBN-200s acceptance and ownership are misassigned.
**Minimal fix**
Split the contract into a pure decision engine that receives complete immutable snapshots and a persistence/orchestration service port implemented by KBN-210. Move `recoverFromPostgres` and ID-based loading to the adapter/service; make pure explanation accept a snapshot.
### KCR-016 — MINOR — Health denial code/state pairs are not correlated by type
**Location**
- `contracts/health-state.v1.ts:35-61` — either denial code can pair with either degraded state.
- `SHARED-CONTRACT.md:188-190` — prose defines `KANBAN_WRITE_UNAVAILABLE` specifically for `write-unavailable`.
**Violation**
Health contract precision. A client can receive a semantically inconsistent authoritative body even after KCR-001s broader state fix.
**Minimal fix**
Make deliberate denial a two-variant union with exact code/state pairing.
---
## Clean checks / invariants that do hold
The review did **not** find a gap in these areas:
- The canon consistently selects current `mosaicstack/stack` + Drizzle/PostgreSQL and rejects greenfield/Prisma revival.
- Every artifact states PostgreSQL is the sole writable SOT and Valkey/files are non-authoritative.
- Generated `TASKS.md`, `mission.json`, and exports are consistently declared read-only and never import sources. KBN-300s importer is scoped to immutable legacy JSON/Vikunja snapshots, not generated projections.
- Recovery config exposes recovery fields only; it contains no direct fail-open, SOT, Coordinator-authority, or gate-waiver knob.
- The Coordinator interface contains no `createTask`, acceptance-edit, gate-waive, certify, merge, release, or provider-close method. `submitForReview` is type-limited to `in_review`, not `done` or `certified`.
- Certifier is consistently final independent gate with no merge authority.
- The seven canonical task status values match across requirements, shared prose, schema, and Coordinators ready/in-review surfaces.
- One-active-lease partial uniqueness, no-self-edge, outbox aggregate-revision/event-type uniqueness, optimistic task/project/mission/milestone versions, and N-1 test categories are explicitly present.
- The file-tree partition is mostly well separated once the ordering/freeze defects in KCR-007 are corrected.
## Required re-review scope
After remediation, re-review at minimum:
1. health/coordinator discriminated unions and mutation-time health proof;
2. proposal/approval/assignment/lease relational model;
3. monotonic fencing and composite bindings;
4. tenant-safe polymorphic relationships;
5. outage-proposal persistence and commands;
6. concrete current-main migration map;
7. corrected dependency graph and exact API/DTO freeze;
8. recovery validator/owner slice;
9. all schema and DTO vocabulary alignment.
## Overall verdict
**NO-GO — 8 BLOCKERs must be resolved before the v1 contract is frozen or parallel implementation begins.**

View File

@@ -1,38 +0,0 @@
# #751 Native Kanban/SOT canonical publication — Ultron final gate
**Verdict: GO** — zero BLOCKER/HIGH findings.
## Scope / integrity
- Reviewed `/home/hermes/agent-work/stack-kanban-canon` staged delta only: exactly 16 documentation/contract artifacts; no unstaged delta; `git diff --cached --check` passes.
- This is a publication canon, not a runtime implementation. The explicit implementation hold prevents feature work until canon merge and prerequisite release (`docs/requirements/native-kanban-sot.md:8-9`; `docs/native-kanban-sot/TASKS.md:45-67`).
## Acceptance mapping and findings
| Requirement area | Final evidence / result |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Sole PostgreSQL SOT, generated projections, outage proposals | Requirements D3/D4 and fixed invariants prohibit alternate writers and import (`docs/requirements/native-kanban-sot.md:22-23,32-44`). Health contract keeps public observation separate from branded transaction-local proof (`contracts/health-state.v1.ts:44-84`) and freezes 503/409/502/504 mappings (`:91-184`). Proposal table uses workspace-aware event FKs (`contracts/kanban-schema.v1.ts:847-908`); exact submission/acceptance transaction semantics are specified (`SHARED-CONTRACT.md:81-89`). PASS. |
| Workspace tenancy, planning, assignments, evidence | Workspace-composite task and proposal relations plus active-member rules are explicit (`SHARED-CONTRACT.md:40-48`; `kanban-schema.v1.ts:587-637,875-908`). Lease/checkpoint relations bind workspace/task/assignment/session/fence, with one active lease and bigint fencing (`:1062-1114`). PASS. |
| Coordinator, gates, concurrency/recovery | Pure Coordinator has snapshot-only decision methods (`mechanical-coordinator.v1.ts:186-198`); persistence port owns locked ID validation and recovery (`:371-407`). Requirements forbid Coordinator scope/gate/certification/merge authority and Certifier merge authority (`requirements:39-40`; `MISSION-MANIFEST.md` authority table). Recovery validator rejects unknown fields, PITR/WAL/RPO/storage/high-assurance violations (`recovery-posture.v1.ts:193-369`). PASS. |
| Migration/N-1/API/task decomposition | N-1 expand/backfill/compatibility/switch/contract order and proposal DDL sequence are concrete (`SHARED-CONTRACT.md:69-115`). Frozen exact Gateway/DTO registry and non-overlapping lane ownership/prerequisites are present (`SHARED-CONTRACT.md:244-282`; `TASKS.md:45-67,81-259`). PASS. |
| Documentation / seven owner decisions / evidence | D1D7 are all explicitly ratified (`requirements:20-26`); all 26 REQ sections contain acceptance criteria. Index/manifest/task graph link requirements, frozen contracts, ownership, and evidence. Relative-link audit passes. PASS. |
## Independent verification performed
```text
git diff --cached --check PASS
./node_modules/.bin/prettier --check <all publication paths> PASS
./node_modules/.bin/tsc --noEmit --strict <health/coordinator/recovery> PASS
Python relative Markdown link audit PASS (0 errors)
Python requirement acceptance audit PASS (26 requirements; 0 missing acceptance sections)
Static staged scope/status check PASS (16 staged docs-only; no unstaged delta)
```
The full schema-contract strict type check cannot resolve `drizzle-orm` from this docs-only worktree; this is an environment dependency-resolution limitation, not a contract diagnostic. Independent external publication validation and final re-review record the strict all-four-contract check against the current Stack Drizzle toolchain as PASS.
## Residual items
- **LOW:** implementation must deliver the declared KBN-100/KBN-110/KBN-140 proposal-event-chain, tenant, failure-mapping, and SecReview evidence before P0/P1 release. This is a forward implementation obligation already frozen in the canon, not a publication defect.
- **LOW:** selected infrastructure backup provider/recovery tier and migration/cutover thresholds remain owner-controlled implementation decisions, bounded by the normative recovery contract and change control.
No source, staging, commit, provider, CI, or deployment state was mutated.

View File

@@ -1,368 +0,0 @@
# Native Kanban and Canonical Task SOT — Canonical Requirements
**Status:** RATIFIED and independently approved for canonical publication under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
**Date:** 2026-07-14
**Decision owner:** Jason
**Publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage)
**Implementation foundation:** current `mosaicstack/stack` main only
**Implementation hold:** no feature implementation begins until this canon is squash-merged to `main` with terminal-green CI.
## 1. Purpose
Deliver Mosaic Stack's native project/task control plane and thin writable Kanban on one authoritative PostgreSQL model. This document formalizes the ratified source plan; it does not create a parallel design.
Normative terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are binding as used here.
## 2. Ratified decisions
| # | Ratified decision | Canonical result |
| --- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| D1 | Foundation | Extend current `mosaicstack/stack` main with its existing Drizzle/PostgreSQL, NestJS Gateway, Next.js, Better Auth, and Valkey/BullMQ conventions. No greenfield service and no Prisma revival. |
| D2 | Tenant boundary | `workspace_id` is the hard tenant boundary from the first migration. Teams are authorization groups inside a workspace, never tenant substitutes. |
| D3 | Outage authority — Option A with amendment | PostgreSQL is the sole writable SOT and mutations fail closed whenever DB write-health cannot be proven. The amendment permits deployment-specific **recovery posture only**; it does not permit an alternate writer. Human outage notes are attributable post-recovery proposals, never shadow state. |
| D4 | Generated files | `TASKS.md`, `mission.json`, and any file export are generated, read-only, non-authoritative, and never import sources. Generate on demand; commit only where repository review policy requires a snapshot. |
| D5 | Status model | Task statuses are `backlog`, `ready`, `in_progress`, `blocked`, `in_review`, `done`, `cancelled`. Runtime readiness is orthogonal and computed. |
| D6 | Coordinator approval | Hybrid: manual Project Sub-Orchestrator approval by default; automatic routing only under an explicit, approved, versioned low-risk policy. |
| D7 | Initial migration scope | Project, mission, milestone, task, tags/archive, dependency, assignment, outage proposal, evidence/link, and orchestration state only. Calendar, email, GLPI cache, and personal-brain features remain out of scope. |
## 3. Fixed invariants — every deployment
These are not tier settings and cannot be weakened by deployment configuration.
1. PostgreSQL is the **sole writable source of truth**.
2. The implementation uses Drizzle on current stack main.
3. Kanban and orchestration mutations **fail closed** unless DB write-health is positively proven `healthy`.
4. No failed mutation is redirected to Markdown, JSON, browser storage, Valkey, queue payloads, scratchpads, or provider issues.
5. `TASKS.md` and all file exports are generated, read-only, non-authoritative, and never parsed for import.
6. Human notes created during an outage become attributable proposals only after recovery. They do not reserve work, change status, satisfy a gate, or establish ordering.
7. Valkey is derived, expendable coordination infrastructure. PostgreSQL retains task truth, leases, fencing, audit, and the transactional outbox.
8. The Mechanical Coordinator is non-LLM and deterministic. It may evaluate eligibility, dependencies, approval policy, leases, fencing, heartbeat, retry, expiry, and quarantine. It cannot invent scope, alter acceptance criteria, waive gates, certify, or merge.
9. **Certifier** is the final independent quality-gate role. Certifier may pass, reject, or escalate with evidence; it has no merge authority.
10. Every business and orchestration record is workspace-scoped; cross-workspace relationships are rejected.
11. Every mutation is idempotent and expected-version checked where it changes an aggregate.
12. Stale worker mutations are rejected by monotonically increasing fencing tokens.
13. Audit events are append-only and attributable; authoritative state is reconstructable from PostgreSQL without Valkey or files.
## 4. Configurable recovery posture only
Deployment tiers configure durability and operational recovery targets. They never configure SOT authority, fail-open writes, or gate bypass.
### 4.1 Tier defaults
| Setting | Lite | Standard | High-assurance |
| --------------------------- | --------------------------------------: | ------------------------------------------------------------: | ----------------------------------------------------------------------: |
| Target RPO | 24 hours | 1 hour | **15 minutes** |
| Target RTO | 24 hours | 8 hours | **4 hours** |
| Base backup cadence | Daily | Daily | **Daily** |
| WAL archive cadence | Disabled | Every 15 minutes | **Every 5 minutes** |
| PITR retention | 0 days / disabled | 14 days | **35 days** |
| Restore test frequency | Quarterly | Quarterly | **Monthly** |
| Break-glass drill frequency | Annually | Semiannually | **Quarterly** |
| Off-cluster storage | One encrypted off-cluster backup target | Encrypted off-cluster object storage, separate failure domain | **Encrypted off-cluster base backups and WAL, separate failure domain** |
A deployment MAY override defaults only through the validated recovery-posture contract. An override MUST record actor, reason, effective time, and policy revision. A claimed RPO MUST be no smaller than the actual backup/WAL mechanism can support. Enabling PITR requires WAL archival and off-cluster storage.
## 5. Functional requirements and acceptance criteria
### REQ-SOT-001 — Sole writable PostgreSQL authority
**Requirement:** All project, mission, milestone, task/tag/archive, dependency, assignment, execution/quarantine, lease, checkpoint, approval, outage proposal, event, link, artifact, and outbox mutations MUST commit through Gateway domain services into PostgreSQL.
**Acceptance:**
- Mutation journey tests show web, CLI, MCP, and agents invoke typed Gateway commands.
- Static/process inventory finds no file, Valkey, browser, or provider issue writer acting as canonical state.
- PostgreSQL state survives Valkey loss and reconstructs the same aggregate revisions.
### REQ-SOT-002 — Fail-closed mutation health
**Requirement:** A mutation MUST execute only while health state is `healthy`. `read-only-degraded` and `write-unavailable` MUST return the frozen deliberate-denial error contract and MUST NOT enqueue a hidden write.
**Acceptance:**
- Public health response is a discriminated union; contradictory state/proof combinations fail contract validation.
- Mutation methods accept only a fresh internal PostgreSQL transaction-local write proof, never caller-asserted/public health state.
- Negative tests reject expired proofs, policy-revision mismatch, Valkey-only liveness, and caller-forged `healthy`.
- Fault tests force both degraded states and prove row counts, outbox, files, and Valkey remain unchanged.
- Exact failure mapping proves authoritative 503 denial, retryable 502/504/timeout uncertainty, and 409 version conflict cannot cross-map.
- Replaying the same idempotency key after recovery returns one canonical result.
### REQ-SOT-003 — Generated projections
**Requirement:** `TASKS.md`, `mission.json`, and other exports MUST contain a non-authoritative header, workspace/project IDs, generated time, and source revision. No production parser may mutate DB from an export.
**Acceptance:**
- Generated output matches the API snapshot revision.
- Hand editing a projection fails CI validation or is overwritten by regeneration.
- Repository search finds no import path from generated projections.
### REQ-SOT-004 — Attributable outage proposals
**Requirement:** Human outage notes MAY be captured outside the system but, after recovery, can enter Mosaic only through workspace-scoped `change_proposals` attributed to an authenticated active member. A proposal stores source-note digest, target aggregate/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, and audit links. It MUST NOT silently change canonical state.
**Acceptance:**
- `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` are composite foreign keys to `task_events(workspace_id, id)`; missing and foreign-workspace event IDs fail before commit.
- Submission preallocates the proposal ID and atomically inserts `change_proposal.submitted` for that exact workspace/proposal with the new proposal referencing it.
- Accept locks proposal and target, obtains fresh write proof, checks expected version, executes the normal typed command, and atomically links that command's event for the same workspace/target and proposal causation.
- Negative tests reject missing submission events, foreign-workspace submission/acceptance events, and same-workspace events for an unrelated proposal, aggregate, target, or command.
- Tests prove a pending/rejected proposal cannot claim/order work, satisfy a dependency/gate, or mutate any target directly.
### REQ-TEN-001 — Workspace hard tenancy
**Requirement:** Every canonical business/orchestration row MUST carry `workspace_id`. Workspace-aware constraints and authorization MUST prevent cross-tenant relationships and reads/writes.
**Acceptance:**
- API, repository, import, WebSocket, and Coordinator negative tests reject foreign-workspace IDs without existence oracles.
- Project/task owners use exactly-one user/team references; assignment principals use exactly-one user/team/agent reference; agent/session targets are workspace-consistent.
- User owners, principals, proposers, and decision actors require ACTIVE workspace membership in the authoritative transaction.
- Dependency, project hierarchy, assignment, lease, checkpoint, approval-evidence, link, artifact, proposal target, and both proposal-audit-event composite relationships reject mixed workspaces.
- Tenant context is derived from authenticated authority, never accepted blindly from request data.
### REQ-ID-001 — Workspace identity and service scope
**Requirement:** Users, teams, agents, and agent sessions MUST be bound to a workspace with explicit role/capability scope. Agents MUST NOT receive raw DB credentials.
**Acceptance:**
- Workspace membership and service-identity tests enforce command-family scope.
- Revoked/disabled agents and ended sessions cannot claim, heartbeat, or submit.
### REQ-PLAN-001 — Normalized planning hierarchy
**Requirement:** Canonical planning entities are projects, milestones, missions, mission-milestone associations, and tasks. A task belongs to one required project and at most one mission/milestone/parent task.
**Acceptance:**
- CRUD tests preserve workspace, hierarchy, versions, and lifecycle constraints.
- Mission membership does not duplicate task status.
- Composite project-congruent constraints reject task→mission, task→milestone, task→parent, mission→milestone, and project→current-milestone mismatches.
- Parent and association constraints reject cycles/orphans where applicable.
### REQ-TASK-001 — Canonical task fields
**Requirement:** Tasks MUST support title, description, structured acceptance criteria, canonical status, priority, fractional board rank, accountable owner, assigned specialist role, due/not-before dates, estimate, progress, explicit blocker, retry policy, normalized workspace tags, non-lifecycle archival (`archived_at/by/reason`), metadata, monotonic fencing counter, and optimistic version.
**Acceptance:**
- API and UI round-trip every field without silent loss.
- Current `tasks.tags`, `assignee`, and `due_date` remain declared/preserved during N-1 and backfill to the canonical model without loss.
- Archive hides work without changing its canonical lifecycle status and requires actor/reason/time.
- Invalid status, rank, progress, date, owner, tag, archive, or retry data is rejected.
- Concurrent expected-version updates produce a visible conflict.
### REQ-TASK-002 — Fixed lifecycle and computed readiness
**Requirement:** Human workflow status MUST use the seven ratified values. Dependency/schedule/policy/lease/retry conditions MUST be exposed as computed readiness, not hidden status rewrites.
**Acceptance:**
- A dependency becoming incomplete changes readiness but does not silently rewrite the Kanban column.
- Readiness explanation identifies all active gates.
- State-machine tests reject illegal transitions and require reasons for blocked/cancelled paths.
### REQ-DEP-001 — Dependency DAG
**Requirement:** Workspace-local directed dependencies MUST be unique and acyclic. A task is dependency-eligible only after every blocking predecessor is `done` and completion conditions pass.
**Acceptance:**
- `(workspace_id, predecessor_task_id, successor_task_id)` is unique independent of dependency type.
- Cycle, duplicate, self-edge, and cross-workspace attempts fail before commit.
- Property/concurrency tests prove all blocking predecessors are evaluated.
- UI displays dependency and readiness errors accessibly.
### REQ-ASN-001 — Assignment is not a lease
**Requirement:** Assignment history and execution leases MUST be separate records. One persisted assignment identity freezes task version, exact target agent/session (or exactly-one non-agent principal), specialist role, expiry, state, policy revision, proposer, reason, and timestamps. Approval decisions relate to that assignment with workspace-aware constraints.
**Acceptance:**
- One assignment-state vocabulary is identical across schema, DTO, and engine.
- Lease acquisition accepts IDs only, then reloads and locks assignment, approval, task, and target session to verify workspace, current task version, exact target, state, expiry, and policy revision.
- Reassignment preserves history; assignment may exist without a lease; lease expiry does not erase ownership/evidence.
### REQ-AUD-001 — Semantic audit and outbox
**Requirement:** Mutating commands MUST append semantic `task_events` with actor, correlation, causation, idempotency key, and aggregate versions in the same transaction as state. Notifications MUST flow from a transactional outbox.
**Acceptance:**
- Atomicity tests prove state/event/outbox commit or roll back together.
- Proposal submission and acceptance tests prove their workspace-bound event links identify the exact submission and executed normal command, not merely an existing event UUID.
- Duplicate idempotency keys return the prior result without duplicate events.
- `task_events`, checkpoints, immutable artifacts, and evidence joins are INSERT/SELECT-only for application roles; parent hard deletes are RESTRICTed.
- Normal lifecycle uses archive/cancel, never hard delete; retention purge requires audited break-glass authority and evidence.
- Valkey outage leaves outbox pending and later replayable.
### REQ-API-001 — Typed Gateway command boundary
**Requirement:** Gateway MUST expose workspace-safe project/task/dependency/assignment/link/artifact/change-proposal queries and explicit lifecycle commands. Generic patching MUST NOT bypass claim, heartbeat, review, certify, proposal acceptance, or completion invariants.
**Acceptance:**
- KBN-105 freezes exact route, request, success, denial, conflict, and transport-normalization DTOs before CLI/web implementation.
- DTO validation, authorization, contract, and integration tests cover each command.
- Exact MCP-owned Gateway files are coder3-owned; coder4 consumes only frozen Gateway contracts.
- Endpoint registry aligns web, CLI, MCP, and generated client paths.
- Direct SQL and raw Valkey writes are absent from clients.
### REQ-UI-001 — Writable thin Kanban/List MVP
**Requirement:** Existing Tasks and Projects surfaces MUST become a real-data writable MVP with one shared query contract.
**Acceptance:**
- Users can create/edit/cancel/archive tasks, open task detail, and move cards within/across columns.
- Server validates transition and persists fractional board rank.
- Refresh, reconnect, CLI, MCP, and generated projection show the same revision.
### REQ-UI-002 — Tenant and work context
**Requirement:** UI MUST show workspace context and support filters for project, mission, milestone, status, priority, owner/specialist, due state, and tags.
**Acceptance:**
- Context is visible on every mutation surface.
- Filter tests cannot expose foreign-workspace data.
- Empty/loading/error states are explicit.
### REQ-UI-003 — Dependency, ownership, lease, and audit visibility
**Requirement:** Task detail MUST separate accountable owner, specialist assignment, active session/lease expiry, dependencies/readiness, acceptance criteria, blocker, external links, and audit timeline.
**Acceptance:**
- Each concept renders from its canonical endpoint.
- A lease is never displayed as ownership or completion.
- Conflict and stale-reconnect states require refresh rather than silent overwrite.
### REQ-UI-004 — Accessible interaction
**Requirement:** Kanban MUST support keyboard-accessible moves, non-drag alternatives, responsive layout, and semantic status/error announcements.
**Acceptance:**
- Keyboard journey performs every card transition available by drag.
- Automated accessibility checks and manual responsive checks pass.
### REQ-COORD-001 — Non-LLM Mechanical Coordinator
**Requirement:** Coordinator decisions MUST be deterministic from structured data and versioned policy. It MUST NOT invoke an LLM to interpret scope or acceptance criteria.
**Acceptance:**
- Pure decision engine receives complete immutable snapshots and performs no ID loading, SQL, Gateway, Valkey, or recovery I/O.
- Persistence/service adapter owns ID loading, transaction-local write proof, locking, persistence, and `recoverFromPostgres`.
- Same snapshot and policy revision produce the same eligibility/order explanation.
- Dependency, schedule, durable retry/quarantine, approval, role, and capacity inputs are auditable.
- Code/config inspection finds no model/provider dependency in the scheduling engine.
### REQ-COORD-002 — Eligibility and approval routing
**Requirement:** Only `ready` tasks under active project/mission, passed dependencies/schedule/retry/release policy, and without active lease may be proposed. Manual approval is default; auto-route requires an explicit approved policy revision.
**Acceptance:**
- Unapproved or gated tasks are never leased.
- Every persisted assignment proposal includes task version, exact target agent/session, expiry, state, deterministic reasons, and policy revision.
- Approval is relationally bound to the assignment identity and cannot be supplied as a forgeable proof-by-value DTO.
- Override/reject/reassign requires an attributable reason.
### REQ-COORD-003 — Atomic lease, heartbeat, fencing, and recovery
**Requirement:** Lease acquisition MUST be atomic in PostgreSQL, permit at most one active lease per task, atomically increment the durable per-task fencing counter under task lock, use bigint-safe tokens, require timely acknowledgement/heartbeat, and reject stale workers. Lease and checkpoint relations MUST bind the exact workspace+task+assignment/session+fence.
**Acceptance:**
- Concurrent claim tests yield one winner and strictly increasing fencing tokens.
- Lower/expired tokens and mismatched same-workspace task/assignment/lease/checkpoint IDs fail.
- Token values round-trip as bigint/decimal strings without JavaScript precision loss.
- Coordinator restart reconstructs lease/retry/quarantine state from PostgreSQL alone.
### REQ-COORD-004 — Retry and quarantine
**Requirement:** Missing acknowledgement, agent loss, or execution failure MUST produce a deterministic release, bounded backoff retry, or quarantine outcome according to retry policy. Ambiguous/non-idempotent work requires Sub-Orchestrator action.
**Acceptance:**
- Durable execution state records disposition, attempt/max, next eligibility, terminal reason, actor/policy, timestamps, and version.
- Retry budget/backoff are bounded and tested.
- Exhausted or non-idempotent failures quarantine with workspace-scoped artifact evidence.
- One specialist-role vocabulary is enforced across schema, sessions, assignments, DTOs, and engine.
- No task loops indefinitely or silently returns to ready.
### REQ-GATE-001 — Role and authority chain
**Requirement:** Canonical flow is User → Interaction → Portfolio Orchestrator → Project Sub-Orchestrator → Gateway → domain services → Mechanical Coordinator → specialists → Certifier.
**Acceptance:**
- Role bindings and approvals are queryable and audited.
- Coordinator cannot create scope or waive gates.
- Certifier cannot merge or close provider artifacts.
### REQ-GATE-002 — Independent review and certification
**Requirement:** Author and reviewer MUST differ. Auth, security, tenant, secrets, and data-integrity surfaces MUST receive mandatory SecReview. Certifier is the final quality gate after remediation.
**Acceptance:**
- Gate tests reject author self-review and missing required SecReview.
- Certifier receives complete traceability/evidence and returns pass/reject/escalate.
- A Certifier pass does not grant merge authority.
### REQ-REC-001 — Recovery posture validation
**Requirement:** A deployment MUST select a validated Lite, Standard, or High-assurance posture and MAY override only recovery knobs.
**Acceptance:**
- Runtime invokes normative `validateRecoveryPostureV1`, not shape-only JSON Schema validation.
- Validator rejects PITR/WAL mismatch, impossible RPO, unknown fields, non-encrypted/non-separated storage, and weakened High-assurance values.
- A bounded recovery/infra slice owns parser wiring, override audit, mechanism verification, restore test, and break-glass evidence.
- High-assurance defaults equal RPO 15m/RTO 4h, encrypted off-cluster WAL every 5m, 35d PITR, daily base backup, monthly restore test, and quarterly break-glass.
### REQ-MIG-001 — One-way shadow migration
**Requirement:** Migration from jarvis-brain/Vikunja MUST use inventory, immutable source snapshots/checksums, one-way shadow import, read reconciliation, write freeze, final delta, cutover, and read-only stabilization. Dual writes are forbidden.
**Acceptance:**
- P0 publishes the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract map before any schema lane starts.
- Legacy columns remain in the unified Drizzle declaration for the entire expand/N-1 window.
- Dry-run/apply/verify modes are idempotent and workspace-safe.
- Import lineage preserves source system/key/file/checksum/batch and rejected-record reports.
- Empty DB, production-shape, partial-resume, downgrade/rollback, status-shadow, workspace-backfill, and `mission_tasks.status` retirement tests pass.
- Shadow records cannot auto-dispatch.
### REQ-MIG-002 — Cutover and rollback safety
**Requirement:** Cutover MUST disable legacy writers and switch all clients to Gateway. Before first DB mutation rollback may switch authority back; afterward rollback requires freeze, DB-delta export/reconciliation, and owner decision.
**Acceptance:**
- Process inventory proves no active jarvis-brain/Vikunja project/task writer.
- Cutover rehearsal meets signed reconciliation thresholds.
- No reverse and forward sync run concurrently.
## 6. Explicit non-goals
The P0P3 canon does not authorize:
- replacing Gitea issue/PR storage;
- calendar, email, GLPI cache, CRM, billing, time tracking, or personal-brain migration;
- arbitrary custom workflows/statuses/fields;
- a writable offline/file/Valkey/browser fallback;
- direct client database access;
- LLM scheduling or autonomous scope invention;
- Coordinator gate waiver, certification, merge, release, or provider issue closure;
- Certifier merge authority;
- full mission designer, portfolio analytics, critical-path UX, or advanced board customization in the thin MVP;
- P4/P5 features unless separately released.
## 7. Global release evidence
P0P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.

View File

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

View File

@@ -1,152 +0,0 @@
# Issue #751 — Native Kanban/SOT canonical publication
## Objective
Publish the owner-ratified P0P3 requirements, mission manifest, task decomposition, and frozen shared contracts before feature implementation.
## Authority and decisions
- Owner: Jason
- Plan owner/orchestrator: web1 control plane; takeover by mosaic-100 during Claude quota outage
- Tracking: Mosaic Stack issue #751
- Foundation: current Stack main + Drizzle/PostgreSQL
- Fixed invariants: PostgreSQL sole writable SOT; writes fail closed; exports never import; outage notes become attributable proposals; mechanical Coordinator has no scope/gate/certify/merge authority; Certifier has no merge authority.
- Recovery posture only is configurable through Lite, Standard, and High-assurance profiles.
## Execution log
- 2026-07-14: Existing planner-sol canon remediation reviewed from staging. KCR-001016 claimed resolved; static checks passed.
- 2026-07-14: Independent GPT/Terra re-review dispatched to rev1.
- 2026-07-14: Re-review returned NO-GO: proposal audit-event IDs were not workspace-bound, leaving attribution forgeable; formatter evidence was not reproducible. Focused remediation round 2 routed to planner-sol.
- 2026-07-14: Remediation bound proposal audit links to `task_events(workspace_id,id)`, froze same-transaction semantic validation and negative tests, and made formatter/type/static checks reproducible.
- 2026-07-14: Independent rev1 re-review returned GO with KCR-001016 closed and no new blocker. Canon copied into the issue #751 publication worktree; feature implementation remains held until merge.
- 2026-07-14: Independent publication validation returned FAIL on formatting/trailing whitespace, stale staging wording, ignored review evidence, and missing worktree dependencies. Bounded publication remediation routed to planner-sol; no runtime source change authorized.
- 2026-07-14: Publication remediation installed locked dependencies outside the repository cache, fixed formatting and wording, and preserved docs-only scope. Independent gaterun revalidation returned PASS across staged scope, formatting, lint, typecheck, strict contract compile, links, rollups, review artifacts, and fixed invariants.
- 2026-07-14: Ultron final gate returned GO with zero BLOCKER/HIGH findings; residual LOW items remain explicit implementation obligations.
- 2026-07-14: First commit attempt was correctly blocked by the lint-staged hook because docs contract `.ts` files were outside TypeScript project-service scope. Added a strict no-emit workstream `tsconfig.json` with exact Drizzle declaration paths; targeted contract TSC, contract ESLint, format, full lint/typecheck, strictness, and docs-only scope independently passed.
## Verification evidence
- Initial review: `docs/reports/native-kanban-sot/canon-initial-review-no-go.md`
- Final GO: `docs/reports/native-kanban-sot/canon-final-rereview-go.md`
- Ultron GO: `docs/reports/native-kanban-sot/ultron-final-go.md`
- Pending: PR gates, squash merge, terminal-green CI, and issue closure.
## Publication remediation session — planner-sol
**Assignment:** Remediate only issue #751 publication blockers from `/home/hermes/agent-work/reviews/751-kanban-canon-publication-validation.md`; no source, package, lock, provider, CI, build, deploy, commit, or push action.
**Plan:**
1. Install the locked pnpm dependencies without modifying package metadata.
2. Replace only stale publication/staging wording while retaining the canon-merge and KBN prerequisite implementation hold.
3. Format all changed candidate Markdown and contract TypeScript; remove trailing whitespace.
4. Run worktree `format:check`, `lint`, `typecheck`, strict no-emit contract compilation, tracked/untracked whitespace checks, and static invariants.
5. Verify package/lock/source paths are unchanged and append exact evidence here.
**Budget:** No explicit token cap; bounded docs-only remediation, no exploratory/runtime work.
**TDD:** Not applicable—documentation/contract-publication formatting and wording only; strict/static validation is the primary gate.
## Publication remediation results
### Changes
- `docs/native-kanban-sot/INDEX.md`: replaced staging/pending-GO wording with current publication and independent-GO wording; retained the merge hold and dependency-ordered KBN prerequisite hold.
- `docs/native-kanban-sot/TASKS.md`: replaced “Mos using this staging set” with “Mos / publication control plane”; made the post-merge KBN prerequisite hold explicit.
- Formatted all changed candidate Markdown and four contract TypeScript files with current-worktree Prettier 3.8.1.
- Removed trailing whitespace from candidate Markdown, including both linked review reports.
- Preserved both review reports and their links; they remain ignored by `.gitignore:11` for coordinator force-tracking.
### Dependency installation
The first target-worktree install attempt used the environment's default root-owned pnpm store and failed without changing package metadata:
```text
cd /home/hermes/agent-work/stack-kanban-canon && pnpm install --frozen-lockfile
EACCES: permission denied, open '/root/.local/share/pnpm/store/v10/server/server.json'
```
Successful locked install using an authorized cache outside the repository:
```bash
cd /home/hermes/agent-work/stack-kanban-canon
pnpm install --frozen-lockfile --store-dir /home/hermes/agent-work/pnpm-store
```
Result: PASS, 1,240 packages installed; lockfile resolution skipped as up to date. `node_modules` remains ignored. Tool versions: pnpm 10.6.2, Prettier 3.8.1, TypeScript 5.9.3, Drizzle ORM 0.45.1, Turbo 2.8.16.
### Exact quality-gate results
```text
pnpm format:check
PASS — All matched files use Prettier code style.
pnpm lint
PASS — 23 successful lint tasks.
pnpm typecheck
PASS — 42 successful tasks. Turbo invoked configured dependency build prerequisites as part of the repository's exact typecheck graph; no standalone build command was run.
```
Candidate formatting commands:
```bash
pnpm exec prettier --write <3 tracked rollups + 9 native-kanban artifacts + requirements + scratchpad>
pnpm exec prettier --check <same files>
pnpm exec prettier --ignore-path /dev/null --write \
docs/reports/native-kanban-sot/canon-initial-review-no-go.md \
docs/reports/native-kanban-sot/canon-final-rereview-go.md
pnpm exec prettier --ignore-path /dev/null --check \
docs/reports/native-kanban-sot/canon-initial-review-no-go.md \
docs/reports/native-kanban-sot/canon-final-rereview-go.md
```
Result: PASS. The explicit `/dev/null` ignore path is required because `docs/reports/` is intentionally ignored pending coordinator force-tracking.
Tracked and untracked whitespace checks:
```text
git diff --check
PASS
git diff --no-index --check /dev/null <each untracked/ignored candidate>
PASS for all candidates
```
Strict contract compilation initially could not resolve pnpm-isolated `drizzle-orm` from the external docs directory. A temporary, removed dependency-context symlink made current-worktree resolution explicit:
```bash
LINK=docs/native-kanban-sot/node_modules
ln -s ../../packages/db/node_modules "$LINK"
trap 'unlink "$LINK"' EXIT
pnpm exec tsc \
--noEmit \
--strict \
--skipLibCheck \
--target ES2022 \
--module NodeNext \
--moduleResolution NodeNext \
docs/native-kanban-sot/contracts/*.ts
```
Result: `strict-contract-noemit=PASS`; temporary link removed.
Static result:
```text
proposal-audit-links=PASS
kcr-invariant-regression=PASS
publication-wording=PASS
vocabulary-alignment=PASS
```
### Scope-integrity evidence
Baseline and final hashes are identical:
```text
package.json 93a50eaefc7a0446a56234e427df03f6a2256f8da17c0bede17c22206928c8c0
pnpm-lock.yaml 8b6448d51ac7797c8f782af52a080c0e38ab8bf364f32624f94e636bf5743229
```
`tracked-package-lock-source-unchanged=PASS`: every tracked/untracked nonignored change remains under `docs/`; no package, lock, application source, plugin source, configuration, CI trigger, standalone build/deploy, container, provider, commit, or push action occurred.

View File

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

View File

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

View File

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

View File

@@ -52,21 +52,6 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
### Mos Coordination Boundary
`@mosaicstack/coord` exposes only the transport-neutral `InteractionCoordinationPort`
verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant,
correlation, and interaction-agent identity from authenticated context plus
trusted configuration; callers never provide an orchestration target. It
rejects unconfigured identities, self-delegation, target/correlation drift, and
cross-tenant handoff reads before an adapter call. No dispatch, assignment,
review, merge, or cancellation API exists at this boundary.
M4 uses a deterministic native in-process queue adapter to prove the handoff →
observe → result flow without coupling the contract to tmux. A fleet/tmux
adapter is deferred to the M5 live-deployment seam and must implement the same
port.
## Session and State Model
A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation.

View File

@@ -1,3 +0,0 @@
# Tess Developer Guide
Interaction adapters pass only server-derived actor/tenant scope, channel, and correlation to runtime providers. Durable session state owns inbox/outbox/checkpoint recovery. Use the OpenAPI contract rather than inventing routes; unsupported provider capabilities fail closed.

View File

@@ -1,17 +0,0 @@
# TESS-M4-003 Operator Plugin Sketch
## Memory/retrieval slice — TESS-MEM-001
Introduce a transport-neutral `OperatorMemoryPlugin` in `packages/memory`. The plugin receives a server-derived `{tenantId, ownerId, sessionId}` scope and delegates to a registered `MemoryAdapter`; adapter and namespace are injected configuration, never caller input. Its operations are `capture`, `search`, `recent`, `stats`, and `startupContext`. Results carry configured instance, provenance, and namespace metadata. Capture/redaction occurs before adapter persistence; startup context uses a bounded candidate window ordered so project/flat-file truth takes precedence within returned material.
Registration remains replaceable-adapter based: the existing `registerMemoryAdapter(kind, factory)` / `createMemoryAdapter(config)` seam supplies the injected adapter to `createOperatorMemoryPlugin(config)`. Identity and namespace are configuration data; no interaction-agent name is embedded in keys or defaults.
## Remaining plugin foundations — TESS-PLG-001
- `packages/agent`: capability descriptors for runtime bootstrap, durable inbox/state hooks, and read-only fleet diagnostics. Each capability advertises supported operations and fails closed when absent.
- `packages/mosaic`: a catalog/registration surface for GitOps, fleet diagnostics, runtime bootstrap, Discord, and MCP/skill discovery. Catalog entries describe authority, input schema, and safe/read-only status; they do not invoke provider transports directly.
- Gateway/channel adapters consume these contracts through server-derived actor/tenant context and durable session state, preserving the replaceable-adapter boundary.
## First implementation boundary
The first PR slice should add the operator-memory plugin contract, configuration-injected adapter seam, scope isolation, provenance-bearing retrieval, and tests for namespace isolation plus a differently named configured instance. Durable inbox/outbox remains owned by the existing `DurableSessionCoordinator`; this plugin only supplies bounded context/capture at lifecycle boundaries.

View File

@@ -1,8 +0,0 @@
# TESS-M5-003 Documentation Checklist
- [x] `openapi-tess.yaml`: authenticated interaction endpoints including SSE stream, Mos handoff/observe/result, and memory preferences, insights, and search.
- [x] User guide: authorized session and handoff workflows.
- [x] Admin guide: provisioning, policy, health, and approval boundary.
- [x] Developer guide: scope, durable state, and provider adapter contract.
- [x] Plugin guide: replaceable-adapter, redaction, and identity-as-data rules.
- [x] Operations guide: readiness, recovery, ambiguous-effect safety, and tracing.

View File

@@ -1,12 +0,0 @@
# TESS-MIG-001 — Cutover Procedure
This procedure is evidence-bound. It does not authorize a production cutover until the M5 qualification gate records the required validation.
1. Confirm the gateway has the explicitly registered `runtime.hermes` adapter (`apps/gateway/src/agent/agent.module.ts`) and provider reachability evidence (`apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`).
2. Query the normalized runtime capability surface, not a Hermes API directly. Confirm the session capabilities required for the operation are advertised.
3. Query the transitional matrix through `RuntimeProviderService.transitionalCapabilityMatrix` (`apps/gateway/src/agent/runtime-provider-registry.service.ts`). Kanban, skills, memory, tools, and cron must remain `unsupported`; stop rather than route those operations through Hermes.
4. Route new memory activity through the Mosaic operator-memory plugin path; there is no landed Hermes memory import.
5. Use `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) for orchestration handoff. The interaction agent does not take configured orchestrator authority.
6. Record the qualification evidence and only then update an external deployment/channel binding through its separately authorized operational process.
No claim here authorizes bulk transcript copying, data-schema migration, or enabling an unsupported transitional capability.

View File

@@ -1,11 +0,0 @@
# TESS-MIG-001 — Hermes → Mosaic Evidence Inventory
Hermes is a reference adapter, not a Mosaic core dependency. `packages/agent/src/hermes-runtime-provider.ts` contains the adapter-local `HermesLegacySession` and converts it to core `RuntimeSession`; `packages/types/src/agent/agent-runtime-provider.ts` contains only normalized contracts. `apps/gateway/src/agent/agent.module.ts` explicitly registers the adapter, while `apps/gateway/src/agent/runtime-provider-registry.service.ts` exposes it only through the runtime registry.
| Reference concern | Landed Mosaic evidence | State |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| sessions, hierarchy, streaming, send/attach/terminate | `HermesRuntimeProvider` plus `hermes-runtime-provider.test.ts` | adapted |
| Kanban, skills, memory, tools, cron | normalized matrix in `HermesRuntimeProvider.transitionalCapabilityMatrix`; each is `unsupported` and `assertTransitionalCapability` denies before a transport call | deferred / fail-closed |
| operator memory | `packages/memory/src/operator-memory-plugin.ts`, constructed by `apps/gateway/src/memory/memory.module.ts` and session-scoped by `apps/gateway/src/agent/agent.service.ts` | native Mosaic path |
| orchestration handoff | `InteractionCoordinationService` in `apps/gateway/src/coord/interaction-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | native Mosaic path |
| transcripts, profiles, preferences | no Hermes importer/schema mapping landed | no automatic migration |

View File

@@ -1,14 +0,0 @@
# TESS-MIG-001 — Retention and Legacy Deprecation Policy
## Retention
- Hermes is not a Mosaic persistence authority. The adapter maps runtime behavior only; it does not import or persist Hermes legacy session shapes.
- Mosaic operator memory is scoped by tenant, owner, and session in `packages/memory/src/operator-memory-plugin.ts`; gateway session ownership is derived before that plugin is made available in `apps/gateway/src/agent/agent.service.ts`.
- Existing Hermes archives remain in their source system under its existing retention policy. This project has no landed automatic transcript, profile, or preference migration.
- Any future import requires an explicit, scoped design and redaction/provenance evidence; it must not extend `packages/types` with Hermes schema.
## Deprecation
- Session adapter use remains transitional until M5 qualification demonstrates the normalized provider path.
- Kanban, skills, memory, tools, and cron are not deprecated into a Hermes bridge: they remain explicitly unsupported until their Mosaic-owned contracts are implemented and qualified.
- A future deprecation change must remove the external binding first, retain rollback evidence, and then remove the adapter in a separately reviewed code change. It must not silently replace or widen a registered provider.

View File

@@ -1,10 +0,0 @@
# TESS-MIG-001 — Rollback Procedure
Rollback is configuration/binding reversal, not a database rollback: no Hermes schema migration or automatic data import is implemented by the landed adapter.
1. Stop sending new traffic to the Mosaic Hermes adapter by reverting the external runtime/channel binding through its authorized deployment process.
2. Keep the gateway registration and core contracts unchanged unless a reviewed code rollback is required; `AgentRuntimeProviderRegistry` registration is explicit and non-replacing (`packages/agent/src/runtime-provider-registry.ts`).
3. Do not replay an unsupported Kanban, skills, memory, tools, or cron operation. The transitional matrix is intentionally fail-closed.
4. Preserve Mosaic audit, session, and operator-memory records under their normal scoped retention rules; do not copy them into Hermes as a rollback shortcut.
5. For an in-flight coordination request, use the owned handoff observation/result flow in `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`); do not create a second orchestrator path.
6. Capture the binding reversal, affected scope, correlation IDs, and reason in the approved operational record before retrying a cutover.

View File

@@ -32,7 +32,7 @@ Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction ag
| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged |
| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests |
| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals |
| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | in-progress | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end |
| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end |
| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete |
## Success Criteria

View File

@@ -1,87 +0,0 @@
# TessMos Coordination Contract Sketch
**Task:** TESS-M4-001 · **PRD:** TESS-MOS-001 / AC-TESS-04
## Boundary
Agent identities are deployment data. A configured interaction agent may request
Mos-owned work; the configured orchestration agent owns decomposition, worker
assignment, reviews, and merge decisions. The interaction agent receives a
correlated receipt, read-only activity projection, and terminal result. It has
no dispatch, assignment, review, merge, or cancellation operation.
## `@mosaicstack/coord` interface
```ts
interface CoordinationScope {
readonly actorId: string;
readonly tenantId: string;
readonly correlationId: string;
readonly requesterAgentId: string; // trusted gateway/configuration data
}
interface HandoffRequest {
readonly idempotencyKey: string;
readonly summary: string;
readonly context?: string;
readonly missionId?: string;
}
interface HandoffReceipt {
readonly handoffId: string;
readonly targetAgentId: string;
readonly status: 'accepted' | 'queued';
readonly correlationId: string;
}
interface Handoff {
readonly handoffId: string;
readonly targetAgentId: string;
readonly request: HandoffRequest;
readonly scope: CoordinationScope;
}
interface InteractionCoordinationPort {
handoff(handoff: Handoff): Promise<HandoffReceipt>;
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
}
```
The port deliberately omits generic orchestrator verbs. It is tenant- and
correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`,
and the requester agent from trusted authentication/configuration only.
## HTTP routes
`/api/coord/interaction` is the canonical HTTP coordination prefix for handoff, observe, and result. `/api/coord/mos` remains a backward-compatible alias with the same handlers and DTOs; new integrations use the neutral canonical prefix.
## Enforcement point
`apps/gateway` owns an `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) boundary that compares the
trusted configured requester/target identities and rejects all of the following
before calling a transport: unconfigured requester, self-delegation, target
identity drift, cross-tenant observe/result lookup, and attempts to observe or
receive a result for a handoff outside the originating tenant. The service exposes handoff, observe,
and result only, and delegates delivery to an injected adapter.
M4 ships a native in-process `InMemoryInteractionCoordinationPort` as the concrete,
deterministic adapter. It preserves the immutable handoff ID, tenant, requester
identity, and correlation ID while demonstrating the handoff → observe → result
round trip. It is a queue/port adapter, not a Mos-side consumer.
A future fleet/tmux adapter is a documented M5 deployment seam and must
implement the same `InteractionCoordinationPort`; no channel client or interaction
runtime calls a transport directly.
## Required tests
1. A configured non-default interaction identity can hand off work to a
configured non-default orchestration identity and receive its result.
2. The gateway passes only server-derived scope/identity to the adapter.
3. Self-targeting, target drift, and cross-tenant observe/result all fail closed
without invoking the adapter.
4. The exported public contract has no worker-dispatch, assignment, review,
merge, or cancellation capability.
5. The native adapter round-trips queued work, activity, and a host-recorded
terminal result without a live fleet dependency.

View File

@@ -1,3 +0,0 @@
# Tess Operations and Recovery
Check `/health/ready`, provider health, and effective policy before recovery. Recover durable sessions through the interaction recovery operation; it requeues only interrupted work and does not replay ambiguous external effects. Preserve correlation IDs for incident tracing and use Mos handoff observation/result endpoints for orchestration visibility.

View File

@@ -1,3 +0,0 @@
# Tess Plugin Authoring
Plugins are replaceable adapters. Declare capabilities, derive scope from trusted context, preserve correlation IDs, redact before persistence/egress, and return unsupported operations as fail-closed results. Names and identities are configuration data, not literals in keys or defaults.

File diff suppressed because one or more lines are too long

View File

@@ -33,18 +33,14 @@ Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service iden
6. Every externally caused operation is replay-safe and correlated.
7. Provider capability absence is a denial, not an invitation to shell around it.
## Closed Prerequisite Findings
## Existing Findings That Block Tess
The original M1 findings below are closed by landed controls and retained for audit traceability.
- Command executor lacks server-side enforcement for declared scopes.
- Session list/reuse/destroy surfaces are not owner-filtered consistently.
- MCP schemas accept caller-supplied user identity.
- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists.
- Chat/tool persistence lacks mandatory redaction.
- Sessions/pending Discord output are in-memory and not restart-safe.
- Session GC currently performs globally scoped promotion.
| Former finding | Closed evidence |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Command scope/role enforcement | `apps/gateway/src/commands/command-authorization.service.ts` and its authorization tests enforce the server-side approval boundary. |
| Cross-owner session access | Gateway session ownership tests cover server-derived owner and tenant scope. |
| Caller-controlled MCP identity | MCP tools derive actor and tenant from authenticated gateway context. |
| Missing Discord ingress allowlists | `apps/gateway/src/plugin/plugin.module.ts` requires the guild, channel, and user allowlist environment values; `apps/gateway/src/plugin/discord-ingress.security.spec.ts` exercises denial and configured ingress. |
| Missing redaction before persistence/egress | Gateway and log redaction coverage verifies sensitive content is classified before durable storage or channel delivery. |
| In-memory-only restart safety | `packages/agent/src/durable-session.test.ts` reconstructs durable identity, inbox/outbox, checkpoints, and handoffs after simulated restart. |
| Globally scoped session GC | `apps/gateway/src/gc/session-gc.service.spec.ts` verifies session-only collection and the absence of automatic global collection entry points. |
These controls remain subject to the runtime's independent review and release qualification gates.
These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled.

View File

@@ -1,5 +0,0 @@
# Tess User Guide
All HTTP interaction calls require authenticated session credentials and `X-Correlation-Id`. Use `GET /api/interaction/{agentName}/sessions?provider=...` to list only visible runtime sessions, then enroll with `POST .../sessions/{sessionId}/enroll` body `{providerId,runtimeSessionId}`. Attach uses `{mode:"read"}`; send uses `{content,idempotencyKey}`. Stop requires `{approvalRef}` and fails with 403 without the exact durable approval. Recovery only requeues interrupted durable work.
Memory is user-scoped: preferences support list/get/upsert/delete; insights support list/get/create/delete; search body is `{query,limit?,maxDistance?}`. Mos work is handed off with `POST /api/coord/mos/handoff`; observe and result use the returned handoff ID.

View File

@@ -1,18 +1,18 @@
# Tess Verification Matrix
| Acceptance criterion | Requirements | Planned evidence | Gate |
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | M4 contract/gateway native-port handoff → observe → result round trip; configurable identity, target-drift and tenant-denial tests; M4-V fleet authority qualification | M4-001, M4-V |
| AC-TESS-05 | TESS-HRM-001, TESS-MEM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix; operator-memory plugin (TESS-MEM-001) reachable end-to-end — env-configured plugin registered + AgentService session-bound server-derived {tenantId,ownerId,sessionId} scoped search/capture, cross-tenant reuse denied before plugin call (M4-W-001 spine: #736 plugin + #739 consumer) | M4-V |
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V |
| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone |
| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V |
| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V |
| Acceptance criterion | Requirements | Planned evidence | Gate |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V |
| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V |
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V |
| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone |
| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V |
| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V |
## Security Abuse Suite Minimum

View File

@@ -1,19 +0,0 @@
# TESS-HRM-001 — Hermes runtime adapter boundary
## Normalized provider surface
`HermesRuntimeProvider` implements the existing Mosaic-owned `AgentRuntimeProvider` unchanged. Its public surface is therefore `capabilities`, `health`, session list/tree, stream, send, attach/detach, and terminate, accepting only `RuntimeScope`, `RuntimeMessage`, `RuntimeSession`, `RuntimeStreamEvent`, and other types from `@mosaicstack/types`. Provider id is `runtime.hermes`.
The provider receives a narrow injected `HermesRuntimeTransport`, whose method names and inputs may represent Hermes API operations but whose return values are explicitly private `HermesLegacy*` types defined only in `packages/agent/src/hermes-runtime-provider.ts`. Mapping functions convert those private values to Mosaic sessions, state, hierarchy, and stream events. Capability negotiation maps a supplied Hermes feature inventory onto the fixed Mosaic runtime capability vocabulary; no unknown/ambiguous legacy feature is advertised. Unsupported Mosaic operations throw the typed fail-closed `capability_unsupported` provider error before a transport call.
## Boundary line
**Hermes legacy schema ends at `HermesRuntimeTransport` and its private adapter-local `HermesLegacy*` definitions in `packages/agent`.** `packages/types` is never changed to contain a Hermes field, enum, identifier, session shape, status, or capability. `apps/gateway` registers/resolves the provider only through `AgentRuntimeProvider` and receives normalized values only. Identity remains server-derived `RuntimeScope` data and is passed to the injected transport as context, never reconstructed from a legacy response.
## Initial mapping and safety posture
- Hermes conversation/thread identifiers map to opaque Mosaic `RuntimeSession.id`; parent linkage maps only when a known parent exists.
- Hermes status strings map through a closed lookup to `RuntimeSessionState`; unknown statuses become `failed`, never a permissive active state.
- Legacy stream chunks map to `message.delta` / `message.complete`; malformed or unsupported events become a normalized `runtime.error` event.
- Send, attach, and terminate require the normalized capability first. `terminate` continues to be approval-bound by the gateway service; the adapter does not weaken gateway authority.
- Kanban, skills, memory, tools, and cron are capability-inventory entries for this transitional adapter, not additions to the core runtime contract. They are reported as explicitly unsupported until a Mosaic-owned capability contract exists.

View File

@@ -1,238 +0,0 @@
# Tess / Option 2 runtime-portability qualification — 2026-07-14
**Issue context:** #706#711 and runtime-neutral Mos follow-up #754
**Qualified revision:** `d0771835542d` (`origin/main` at review time)
**Reviewer/runtime:** Independent Pi lane requested as `openai-codex/gpt-5.6-sol:high`
**Runtime resolution note:** Mosaic warned that `gpt-5.6-sol` was not present in the provider model catalog and proceeded with it as a custom model ID. This warning was part of the original qualification log and is material provenance; downstream claims must not treat catalog recognition as verified.
**Verdict:** REQUEST CHANGES
**Evidence type:** Point-in-time qualification; later commits and PR #757 must be reviewed separately
## Purpose and provenance
This report preserves the complete independent qualification that was previously available only in `/tmp/tess-option2-qualification.log`. It distinguishes passing component tests from the missing operational proof required for identity-continuous Mos failover.
No credential values, OAuth tokens, Discord tokens, device codes, or auth-file contents are included. Commands and results are retained so another environment can reproduce or challenge the findings.
---
# 1. Verdict
## **REQUEST CHANGES**
The current Option 2 implementation is a useful portability foundation, but it is **not qualified against AC-TESS-01..11** and is not equivalent to true same-Mos-identity failover.
Primary blockers:
1. **AC-TESS-01/02:** The required `mosaic tess` command does not exist; only `mosaic interaction` is registered (`packages/mosaic/src/commands/interaction.ts:60`). The cross-surface test proves CLI enrollment followed by Discord approval/stop, not bidirectional Discord/CLI chat streaming.
2. **AC-TESS-04:** Fleet/tmux and Matrix providers are implemented as libraries but are not registered in the production gateway. `AgentModule` registers only Hermes (`apps/gateway/src/agent/agent.module.ts:34`).
3. **Mos handoff is not operational or durable:** Production uses `InMemoryInteractionCoordinationPort` (`apps/gateway/src/coord/coord.module.ts:18`), with no Mos-side consumer. Restart loses handoff ownership, idempotency, activity, and results.
4. **AC-TESS-06/10:** Restart tests are good local persistence tests, but no real connector/harness failover or exercised rollback exists. Rollback is documentation-only.
5. **AC-TESS-08:** The parity suite validates a selected shared intersection using mocked transports. Matrix is not production-wired and tmux drops the runtime message idempotency key before delivery.
6. **AC-TESS-09:** M5 qualification remains `not-started`; no live Discord, Matrix homeserver, tmux/Mos consumer, Claude Code/Pi/Codex failover, or deployment rollback was tested.
7. **PR #750 mismatch:** Its description promises send-error coverage as HTTP 400, but both gateway and TUI test use HTTP 403 (`packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts:25-34`).
### AC disposition
| AC | Result | Evidence |
| --- | ----------------- | ------------------------------------------------------------------------------- |
| 01 | **Fail** | No `mosaic tess`; no bidirectional same-session chat/stream E2E |
| 02 | **Fail** | Generic CLI exists, but fleet/Matrix providers are unreachable in production |
| 03 | Pass | Pi profile/model/reasoning/effective-policy tests passed |
| 04 | **Fail** | No registered fleet provider or real Mos consumer |
| 05 | Partial | Hermes normalization/fail-closed matrix passes; live capability path is limited |
| 06 | Partial | PGlite restart/idempotency passes; no actual harness failover |
| 07 | Partial | Focused denial/replay tests pass; full M5 abuse qualification absent |
| 08 | Partial | Mocked shared-intersection parity passes; Matrix not operationally wired |
| 09 | **Fail** | Baselines/CI green, but required E2E/security/rollback qualification absent |
| 10 | **Fail** | Inventory incomplete/inconsistent; rollback not exercised |
| 11 | Pass/ledger stale | Documentation and sitemap exist; plugin/catalog ledger remains unresolved |
---
# 2. Exact test commands and results
Initial focused attempts failed before collection because this detached worktree had no dependencies:
```bash
pnpm --filter @mosaicstack/agent exec vitest run ...
```
Result: startup failure, `Cannot find module 'vitest/config'`.
Setup used:
```bash
corepack pnpm --store-dir /home/jarvis/.local/share/pnpm/store/v10 \
install --frozen-lockfile --ignore-scripts
```
Result: PASS, 1,240 packages linked.
```bash
corepack pnpm turbo run build \
--filter='@mosaicstack/gateway^...' \
--filter='@mosaicstack/mosaic^...'
```
Result: **17/17 dependency builds successful**.
### Focused suites
```bash
corepack pnpm --filter @mosaicstack/agent exec vitest run \
src/runtime-provider-parity.test.ts \
src/matrix-native-runtime-provider.test.ts \
src/tmux-fleet-runtime-provider.test.ts \
src/durable-session.test.ts \
src/hermes-runtime-provider.test.ts
```
Result: **5 files, 39/39 tests passed**.
```bash
corepack pnpm --filter @mosaicstack/gateway exec vitest run \
src/agent/durable-session.repository.test.ts \
src/__tests__/integration/tess-cross-surface.integration.test.ts \
src/plugin/discord-ingress.security.spec.ts \
src/coord/interaction-coordination.service.test.ts \
src/coord/interaction-coordination.routing.e2e.test.ts \
src/agent/hermes-runtime-reachability.e2e.test.ts
```
Result: **6 files, 36/36 tests passed**. PGlite close/reopen recovery passed in 504 ms.
```bash
corepack pnpm --filter @mosaicstack/mosaic exec vitest run \
src/fleet/matrix-native-runtime-transport.test.ts \
src/fleet/tess-service-profile.test.ts \
src/commands/interaction.test.ts \
src/tui/gateway-api.interaction-errors.test.ts
```
Result: **4 files, 15/15 tests passed**.
```bash
corepack pnpm --filter @mosaicstack/coord exec vitest run \
src/__tests__/interaction-coordination.test.ts
```
Result: **1 file, 7/7 tests passed**.
```bash
corepack pnpm --filter @mosaicstack/gateway exec vitest run \
src/agent/interaction.controller.test.ts \
src/commands/command-authorization.service.spec.ts \
src/agent/__tests__/runtime-provider-registry.service.test.ts
```
Result: **3 files, 27/27 tests passed**.
Focused total: **124/124 tests passed** after dependency setup.
### Baselines
```bash
TURBO_FORCE=true corepack pnpm typecheck
```
Result: **42/42 tasks successful**.
```bash
TURBO_FORCE=true corepack pnpm lint
```
Result: **23/23 tasks successful**.
```bash
corepack pnpm format:check
```
Result: **PASS — all files matched Prettier style**.
```bash
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
-r mosaicstack/stack -n 1796
```
Result: **SUCCESS** at `d0771835542d`; all test, build, sanitization, typecheck, lint, format, and publish steps green.
No tracked files outside the pre-existing `.mosaic/orchestrator/*` launcher changes were modified.
---
# 3. Stale ledger inconsistencies
1. `docs/tess/MISSION-MANIFEST.md` still says:
- current milestone M1;
- progress 0/5;
- M2/M3/M5 not started.
2. `docs/tess/TASKS.md` says:
- M4-V failed;
- M4-W-001 and TESS-PLG-001 in progress;
- M5-V not started.
3. Provider issue state conflicts:
- #707#709 remain open although M1M3 rows are recorded done/pass.
- #710 and #711 are closed although M4-V failed and M5-V is not started.
4. M5 work was marked done despite depending on failed M4-V.
5. TESS-M3-002 says `mosaic tess` is done, but only `mosaic interaction` exists.
6. PR #750 removed stale service references from operational docs, but `docs/tess/TASKS.md` still contains `MosCoordinationService` in historical notes.
7. `docs/tess/MIGRATION-INVENTORY.md` remains an “initial inventory” with several capabilities marked `adapt`; `M5-MIGRATION-INVENTORY.md` marks grouped capabilities deferred/fail-closed. Neither supplies the complete owner/evidence matrix AC-TESS-10 requires.
8. TESS-M2-FUP-001 remains real: the unkeyed SHA-256 compatibility branch still exists at `durable-session.repository.ts:427-431`.
9. TESS-PLG-001 claims catalog registration was folded into W-001, but production evidence shows provider registration in the gateway—not a completed `packages/mosaic` plugin catalog.
---
# 4. Gap to true same-Mos-identity failover
Current code can relaunch the same roster name under another runtime and can rebind a durable interaction session to another provider/runtime ID. That is **replacement**, not identity-continuous failover.
Missing pieces:
- No canonical logical Mos identity independent of harness-native session IDs.
- No exclusive connector lease or monotonic fencing epoch; session rebinding is effectively last-write-wins.
- No stale-holder rejection preventing the old harness from continuing side effects.
- No normalized Claude Code/Pi/Codex checkpoint/import/export adapters.
- No durable Mos coordination transport or Mos consumer.
- No canonical handoff containing mission/task refs, git state, causal sequence, pending operations, capability requirements, and acknowledgements.
- No end-to-end receipt journal across connectors.
- Matrix has deterministic transaction IDs, but tmux delivery discards `RuntimeMessage.idempotencyKey`.
- No fault-injection test transferring Mos among Claude Code, Pi, and Codex and then rolling back.
---
# 5. Minimal follow-up issue decomposition
| Order | Issue | Minimum acceptance criteria |
| ----- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Logical identity and security fencing** | Server-derived `{tenant, logicalAgentId, connectorId, harness, leaseEpoch, scopes, expiry}`; signed/fenced execution grant; stale/forged/cross-tenant grants denied and audited; no connector credential in handoffs |
| 2 | **Durable connector lease** | PostgreSQL-backed exclusive lease with CAS, monotonic epoch, TTL/heartbeat, explicit takeover, and gateway rejection of stale holders; connectors for Claude Code, Pi, and Codex |
| 3 | **Canonical handoff/checkpoint** | Versioned, sealed schema containing canonical mission/task/git references, checkpoint digest, causal sequence, required capabilities, pending/ambiguous operation references, and source/destination acknowledgement; no raw secrets or mandatory harness transcript |
| 4 | **Exactly-once connector journal** | Durable operation IDs and receipts; idempotency propagated through every adapter; Matrix transaction mapping; tmux replaced or wrapped with receiver-side durable dedupe; ambiguous effects remain held for authorized reconciliation |
| 5 | **Cross-harness failover and rollback E2E** | Real Mos identity moves Claude Code → Pi → Codex and back; inject crashes before/after lease transfer, handoff persistence, send, and acknowledgement; stale connector fenced; no duplicate side effects; canonical state preserved; rollback evidence published |
| 6 | **Generic gateway research ADR** | Evaluate LiteLLM subscription OAuth and Bifrost concepts without adding either to core; include terms/security review, credential lifecycle, tenant mapping, budgets, failover semantics, and adapter-only prototype |
## Generic gateway placement
Allowed topology:
```text
Discord / CLI / web
Mosaic Gateway: auth, tenant scope, policy, approvals, audit
IProviderAdapter / AgentRuntimeProvider
optional LiteLLM or Bifrost egress proxy
upstream provider
```
- **LiteLLM ChatGPT subscription OAuth:** research-only, opt-in, behind an adapter. Subscription credentials require explicit terms, revocation, scope, token-storage, and audit review. They must never become Mosaic identity or core configuration.
- **Bifrost:** virtual keys are downstream proxy credentials, not Mosaic principals. Budget and failover concepts may inform Mosaic routing, but tenant policy, authorization, and audit remain in Mosaic.
- Neither product may introduce schemas into Mosaic core, receive direct calls from channels/agents, or bypass `IProviderAdapter`/`AgentRuntimeProvider`.
- Mosaic should also correct its existing “all providers unhealthy → use one anyway” fallback behavior before adopting more automatic failover (`routing-engine.service.ts:204-212`).

View File

@@ -1,77 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { RuntimeScope } from '@mosaicstack/types';
import { HermesRuntimeProvider, type HermesRuntimeTransport } from './hermes-runtime-provider.js';
const scope: RuntimeScope = { actorId: 'a', tenantId: 't', channelId: 'c', correlationId: 'r' };
const transport = (capabilities = ['session.list', 'session.tree']): HermesRuntimeTransport => ({
capabilities: vi.fn(async () => capabilities),
health: vi.fn(async () => ({ status: 'healthy' })),
sessions: vi.fn(async () => [
{
conversation_id: 'child',
agent_id: 'hermes-a',
parent_conversation_id: 'parent',
status: 'running',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
{
conversation_id: 'parent',
agent_id: 'hermes-a',
status: 'unknown',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]),
stream: async function* () {},
send: vi.fn(),
attach: vi.fn(),
detach: vi.fn(),
terminate: vi.fn(),
});
describe('HermesRuntimeProvider normalization boundary', () => {
it('returns an exhaustive fail-closed transitional capability matrix', async () => {
const provider = new HermesRuntimeProvider(transport());
await expect(provider.transitionalCapabilityMatrix(scope)).resolves.toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
it('denies unsupported transitional capabilities without calling Hermes', async () => {
const hermes = transport();
const provider = new HermesRuntimeProvider(hermes);
await expect(provider.assertTransitionalCapability('memory', scope)).rejects.toMatchObject({
code: 'capability_unsupported',
});
expect(hermes.capabilities).not.toHaveBeenCalled();
});
it('normalizes legacy sessions without exposing legacy fields', async () => {
const provider = new HermesRuntimeProvider(transport());
await expect(provider.listSessions(scope)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'child', runtimeId: 'hermes-a', state: 'active' }),
]),
);
const result = await provider.listSessions(scope);
expect(result[0]).not.toHaveProperty('conversation_id');
});
it('forms normalized hierarchy and fails closed for unbridged operations', async () => {
const provider = new HermesRuntimeProvider(transport());
await expect(provider.getSessionTree(scope)).resolves.toEqual([
expect.objectContaining({
session: expect.objectContaining({ id: 'parent', state: 'failed' }),
children: [expect.objectContaining({ session: expect.objectContaining({ id: 'child' }) })],
}),
]);
await expect(
provider.sendMessage('parent', { content: 'x', idempotencyKey: 'i' }, scope),
).rejects.toMatchObject({ code: 'capability_unsupported' });
});
});

View File

@@ -1,217 +0,0 @@
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionState,
RuntimeSessionTree,
RuntimeStreamEvent,
TransitionalCapabilityInventoryEntry,
TransitionalCapabilityInventoryProvider,
TransitionalRuntimeCapability,
} from '@mosaicstack/types';
const HERMES_PROVIDER_ID = 'runtime.hermes';
const TRANSITIONAL_CAPABILITIES: readonly TransitionalRuntimeCapability[] = [
'kanban',
'skills',
'memory',
'tools',
'cron',
];
const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [
'session.list',
'session.tree',
'session.stream',
'session.send',
'session.attach',
'session.terminate',
];
/** Legacy transport boundary. These shapes are intentionally adapter-local. */
export interface HermesLegacySession {
conversation_id: string;
agent_id: string;
parent_conversation_id?: string;
status: string;
created_at: string;
updated_at: string;
}
export interface HermesRuntimeTransport {
capabilities(scope: RuntimeScope): Promise<string[]>;
health(scope: RuntimeScope): Promise<{ status: string; detail?: string }>;
sessions(scope: RuntimeScope): Promise<HermesLegacySession[]>;
stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent>;
send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle>;
detach(attachmentId: string, scope: RuntimeScope): Promise<void>;
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
}
export class HermesRuntimeProviderError extends Error {
constructor(
readonly code: 'capability_unsupported' | 'invalid_request',
message: string,
) {
super(message);
this.name = HermesRuntimeProviderError.name;
}
}
/**
* Transitional Hermes adapter. Legacy identifiers and schemas do not cross this
* boundary: callers only observe Mosaic AgentRuntimeProvider contracts.
*/
export class HermesRuntimeProvider
implements AgentRuntimeProvider, TransitionalCapabilityInventoryProvider
{
readonly id = HERMES_PROVIDER_ID;
constructor(private readonly transport: HermesRuntimeTransport) {}
/**
* Full AC-TESS-05 migration inventory. These operations are deliberately
* unsupported until their Mosaic-owned plugin contracts exist.
*/
async transitionalCapabilityMatrix(
_scope: RuntimeScope,
): Promise<TransitionalCapabilityInventoryEntry[]> {
return TRANSITIONAL_CAPABILITIES.map((capability) => ({ capability, status: 'unsupported' }));
}
async assertTransitionalCapability(
capability: TransitionalRuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
const entry = (await this.transitionalCapabilityMatrix(scope)).find(
(candidate) => candidate.capability === capability,
);
if (!entry || entry.status !== 'supported') {
throw new HermesRuntimeProviderError(
'capability_unsupported',
`Hermes transitional capability is unsupported: ${capability}`,
);
}
}
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
const legacyCapabilities = await this.transport.capabilities(scope);
return {
supported: RUNTIME_CAPABILITIES.filter((capability) =>
legacyCapabilities.includes(capability),
),
};
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
const health = await this.transport.health(scope);
return {
status: health.status === 'healthy' || health.status === 'degraded' ? health.status : 'down',
checkedAt: new Date().toISOString(),
...(health.detail ? { detail: health.detail } : {}),
};
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
await this.requireCapability('session.list', scope);
return (await this.transport.sessions(scope)).map((session) => this.session(session));
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
await this.requireCapability('session.tree', scope);
const sessions = (await this.transport.sessions(scope)).map((session) => this.session(session));
const nodes = new Map<string, RuntimeSessionTree>(
sessions.map((session) => [session.id, { session, children: [] }]),
);
const roots: RuntimeSessionTree[] = [];
for (const session of sessions) {
const node = nodes.get(session.id)!;
const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}
async *streamSession(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
await this.requireCapability('session.stream', scope);
yield* this.transport.stream(sessionId, cursor, scope);
}
async sendMessage(
sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
await this.requireCapability('session.send', scope);
if (!message.content.trim())
throw new HermesRuntimeProviderError('invalid_request', 'Message content is required');
await this.transport.send(sessionId, message, scope);
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
await this.requireCapability('session.attach', scope);
return this.transport.attach(sessionId, mode, scope);
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
await this.transport.detach(attachmentId, scope);
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
await this.requireCapability('session.terminate', scope);
if (!approvalRef.trim())
throw new HermesRuntimeProviderError('invalid_request', 'Termination approval is required');
await this.transport.terminate(sessionId, approvalRef, scope);
}
private async requireCapability(
capability: RuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
if (!(await this.capabilities(scope)).supported.includes(capability)) {
throw new HermesRuntimeProviderError(
'capability_unsupported',
`Hermes does not bridge ${capability}`,
);
}
}
private session(value: HermesLegacySession): RuntimeSession {
return {
id: value.conversation_id,
providerId: this.id,
runtimeId: value.agent_id,
...(value.parent_conversation_id ? { parentSessionId: value.parent_conversation_id } : {}),
state: state(value.status),
createdAt: value.created_at,
updatedAt: value.updated_at,
};
}
}
function state(value: string): RuntimeSessionState {
return (
(
{ running: 'active', waiting: 'idle', starting: 'starting', stopped: 'stopped' } as Record<
string,
RuntimeSessionState
>
)[value] ?? 'failed'
);
}

View File

@@ -2,6 +2,4 @@ export const VERSION = '0.0.0';
export * from './runtime-provider-registry.js';
export * from './tmux-fleet-runtime-provider.js';
export * from './hermes-runtime-provider.js';
export * from './matrix-native-runtime-provider.js';
export * from './durable-session.js';
export * from './tess-durable-session.js';

View File

@@ -1,153 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { RuntimeScope, RuntimeStreamEvent } from '@mosaicstack/types';
import {
type MatrixRuntimeSession,
type MatrixRuntimeTransport,
MatrixNativeRuntimeProvider,
type MatrixReadAuthority,
type MatrixWriteAuthority,
} from './matrix-native-runtime-provider.js';
const scope: RuntimeScope = {
actorId: 'operator-1',
tenantId: 'tenant-a',
channelId: 'matrix-control',
correlationId: 'corr-1',
};
const session: MatrixRuntimeSession = {
id: 'native-1',
runtimeId: '@worker:example.test',
state: 'active',
createdAt: '2026-07-13T00:00:00.000Z',
updatedAt: '2026-07-13T00:00:00.000Z',
};
function transport(): MatrixRuntimeTransport {
return {
health: vi.fn(async () => ({
status: 'healthy' as const,
checkedAt: '2026-07-13T00:00:00.000Z',
})),
listSessions: vi.fn(async () => [session]),
verifySession: vi.fn(async (sessionId) => {
if (sessionId !== session.id) throw new Error('unexpected session');
return session;
}),
stream: vi.fn(async function* (): AsyncIterable<RuntimeStreamEvent> {
yield {
type: 'message.delta',
sessionId: 'native-1',
cursor: 'cursor-1',
occurredAt: '2026-07-13T00:00:00.000Z',
content: 'hello',
};
}),
send: vi.fn(async () => undefined),
terminate: vi.fn(async () => undefined),
};
}
function readAuthority(): MatrixReadAuthority {
return { canRead: vi.fn(async () => true) };
}
function writeAuthority(): MatrixWriteAuthority {
return {
canWrite: vi.fn(async () => true),
assertAuthorized: vi.fn(async () => undefined),
};
}
describe('MatrixNativeRuntimeProvider contract boundary', (): void => {
it('advertises the concrete Matrix operations and returns only normalized sessions', async (): Promise<void> => {
const provider = new MatrixNativeRuntimeProvider({
transport: transport(),
readAuthority: readAuthority(),
});
await expect(provider.capabilities(scope)).resolves.toEqual({
supported: [
'session.list',
'session.tree',
'session.stream',
'session.send',
'session.attach',
'session.terminate',
],
});
await expect(provider.listSessions(scope)).resolves.toEqual([
expect.objectContaining({ id: 'native-1', providerId: 'runtime.matrix' }),
]);
});
it('binds read attachments to immutable scope and rejects control mode before transport access', async (): Promise<void> => {
const matrix = transport();
const provider = new MatrixNativeRuntimeProvider({
transport: matrix,
readAuthority: readAuthority(),
attachmentIdFactory: () => 'attachment-1',
now: () => new Date('2026-07-13T00:00:00.000Z'),
});
await expect(provider.attach('native-1', 'control', scope)).rejects.toMatchObject({
code: 'forbidden',
});
expect(matrix.verifySession).not.toHaveBeenCalled();
await provider.attach('native-1', 'read', scope);
await expect(
provider.detach('attachment-1', { ...scope, tenantId: 'other' }),
).rejects.toMatchObject({
code: 'forbidden',
});
});
it('validates messages and applies exact Matrix authority after bound-session verification', async (): Promise<void> => {
const matrix = transport();
const writes = writeAuthority();
const provider = new MatrixNativeRuntimeProvider({
transport: matrix,
readAuthority: readAuthority(),
writeAuthority: writes,
});
await expect(
provider.sendMessage('native-1', { content: '', idempotencyKey: 'msg-1' }, scope),
).rejects.toMatchObject({
code: 'invalid_request',
});
expect(matrix.verifySession).not.toHaveBeenCalled();
await provider.sendMessage('native-1', { content: 'hello', idempotencyKey: 'msg-1' }, scope);
expect(writes.assertAuthorized).toHaveBeenCalledWith({
operation: 'session.send',
sessionId: 'native-1',
scope,
});
expect(matrix.send).toHaveBeenCalledWith(
'native-1',
{ content: 'hello', idempotencyKey: 'msg-1' },
scope,
);
});
it('streams only after exact read authorization', async (): Promise<void> => {
const matrix = transport();
const provider = new MatrixNativeRuntimeProvider({
transport: matrix,
readAuthority: readAuthority(),
});
await expect(collect(provider.streamSession('native-1', 'cursor-0', scope))).resolves.toEqual([
expect.objectContaining({ type: 'message.delta', sessionId: 'native-1' }),
]);
expect(matrix.stream).toHaveBeenCalledWith('native-1', 'cursor-0', scope);
});
});
async function collect(stream: AsyncIterable<RuntimeStreamEvent>): Promise<RuntimeStreamEvent[]> {
const values: RuntimeStreamEvent[] = [];
for await (const value of stream) values.push(value);
return values;
}

View File

@@ -1,351 +0,0 @@
import { randomUUID } from 'node:crypto';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
const MATRIX_PROVIDER_ID = 'runtime.matrix';
const ATTACHMENT_TTL_MS = 5 * 60 * 1_000;
/** A verified native runtime session. Matrix room and event details remain transport-local. */
export interface MatrixRuntimeSession {
id: string;
runtimeId: string;
parentSessionId?: string;
state: RuntimeSession['state'];
createdAt: string;
updatedAt: string;
}
/**
* Narrow native Matrix boundary. The concrete Mosaic transport owns homeserver
* authentication, exact room mapping, Matrix identity checks, and replay cursors.
*/
export interface MatrixRuntimeTransport {
health(scope: RuntimeScope): Promise<RuntimeHealth>;
listSessions(scope: RuntimeScope): Promise<MatrixRuntimeSession[]>;
verifySession(sessionId: string, scope: RuntimeScope): Promise<MatrixRuntimeSession>;
stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent>;
send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
}
export type MatrixRuntimeProviderErrorCode =
| 'capability_unsupported'
| 'forbidden'
| 'invalid_request'
| 'not_found';
export class MatrixRuntimeProviderError extends Error {
constructor(
readonly code: MatrixRuntimeProviderErrorCode,
message: string,
) {
super(message);
this.name = MatrixRuntimeProviderError.name;
}
}
export type MatrixReadOperation =
| 'runtime.health'
| 'session.list'
| 'session.tree'
| 'session.stream'
| 'session.attach';
export interface MatrixReadAuthority {
canRead(input: {
operation: MatrixReadOperation;
scope: RuntimeScope;
sessionId?: string;
}): Promise<boolean>;
}
export interface MatrixWriteAuthority {
canWrite(input: {
operation: 'session.send' | 'session.terminate';
sessionId: string;
scope: RuntimeScope;
approvalRef?: string;
}): Promise<boolean>;
assertAuthorized(input: {
operation: 'session.send' | 'session.terminate';
sessionId: string;
scope: RuntimeScope;
approvalRef?: string;
}): Promise<void>;
}
export interface MatrixNativeRuntimeProviderOptions {
transport: MatrixRuntimeTransport;
readAuthority?: MatrixReadAuthority;
writeAuthority?: MatrixWriteAuthority;
attachmentIdFactory?: () => string;
now?: () => Date;
attachmentTtlMs?: number;
}
interface Attachment {
sessionId: string;
scope: RuntimeScope;
expiresAtMs: number;
}
class DenyMatrixReadAuthority implements MatrixReadAuthority {
async canRead(): Promise<boolean> {
return false;
}
}
class DenyMatrixWriteAuthority implements MatrixWriteAuthority {
async canWrite(): Promise<boolean> {
return false;
}
async assertAuthorized(): Promise<void> {
throw new MatrixRuntimeProviderError(
'forbidden',
'Matrix runtime writes require orchestrator authority',
);
}
}
/**
* Native Matrix adapter behind the Mosaic runtime contract. It accepts only
* stable session IDs; room identifiers, Matrix event schemas, and credentials
* are deliberately confined to the concrete transport implementation.
*/
export class MatrixNativeRuntimeProvider implements AgentRuntimeProvider {
readonly id = MATRIX_PROVIDER_ID;
private readonly readAuthority: MatrixReadAuthority;
private readonly writeAuthority: MatrixWriteAuthority;
private readonly attachmentIdFactory: () => string;
private readonly now: () => Date;
private readonly attachmentTtlMs: number;
private readonly attachments = new Map<string, Attachment>();
constructor(private readonly options: MatrixNativeRuntimeProviderOptions) {
this.readAuthority = options.readAuthority ?? new DenyMatrixReadAuthority();
this.writeAuthority = options.writeAuthority ?? new DenyMatrixWriteAuthority();
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
this.now = options.now ?? (() => new Date());
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
}
async capabilities(_scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
return {
supported: [
'session.list',
'session.tree',
'session.stream',
'session.send',
'session.attach',
'session.terminate',
],
};
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
await this.assertRead('runtime.health', undefined, scope);
return this.options.transport.health(scope);
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
await this.assertRead('session.list', undefined, scope);
const sessions = await this.options.transport.listSessions(scope);
const visible = await Promise.all(
sessions.map((session) =>
this.readAuthority.canRead({ operation: 'session.list', sessionId: session.id, scope }),
),
);
return sessions
.filter((_session, index) => visible[index] === true)
.map((session) => this.runtimeSession(session));
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
await this.assertRead('session.tree', undefined, scope);
const sessions = await this.options.transport.listSessions(scope);
const visible = await Promise.all(
sessions.map((session) =>
this.readAuthority.canRead({ operation: 'session.tree', sessionId: session.id, scope }),
),
);
const runtimeSessions = sessions
.filter((_session, index) => visible[index] === true)
.map((session) => this.runtimeSession(session));
const nodes = new Map<string, RuntimeSessionTree>(
runtimeSessions.map((session) => [session.id, { session, children: [] }]),
);
const roots: RuntimeSessionTree[] = [];
for (const session of runtimeSessions) {
const node = nodes.get(session.id)!;
const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}
async *streamSession(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
await this.assertRead('session.stream', sessionId, scope);
const session = await this.options.transport.verifySession(sessionId, scope);
await this.assertRead('session.stream', session.id, scope);
yield* this.options.transport.stream(session.id, cursor, scope);
}
async sendMessage(
sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
if (!message.content.trim()) {
throw new MatrixRuntimeProviderError(
'invalid_request',
'Matrix runtime message content is required',
);
}
await this.assertWritePermitted('session.send', sessionId, scope);
const session = await this.options.transport.verifySession(sessionId, scope);
await this.assertWriteAuthorized('session.send', session.id, scope);
await this.options.transport.send(session.id, message, scope);
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
if (mode !== 'read') {
throw new MatrixRuntimeProviderError('forbidden', 'Matrix control attach is not permitted');
}
await this.assertRead('session.attach', sessionId, scope);
const session = await this.options.transport.verifySession(sessionId, scope);
await this.assertRead('session.attach', session.id, scope);
const nowMs = this.now().getTime();
this.pruneExpired(nowMs);
const attachmentId = this.attachmentIdFactory();
const expiresAtMs = nowMs + this.attachmentTtlMs;
this.attachments.set(attachmentId, {
sessionId: session.id,
scope: snapshotScope(scope),
expiresAtMs,
});
return {
attachmentId,
sessionId: session.id,
mode,
expiresAt: new Date(expiresAtMs).toISOString(),
};
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
const attachment = this.attachments.get(attachmentId);
if (!attachment)
throw new MatrixRuntimeProviderError('not_found', 'Matrix attachment is not active');
if (this.now().getTime() >= attachment.expiresAtMs) {
this.attachments.delete(attachmentId);
throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment has expired');
}
if (!sameScope(attachment.scope, scope)) {
throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment scope does not match');
}
this.attachments.delete(attachmentId);
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
if (!approvalRef.trim()) {
throw new MatrixRuntimeProviderError(
'invalid_request',
'Matrix termination approval is required',
);
}
await this.assertWritePermitted('session.terminate', sessionId, scope, approvalRef);
const session = await this.options.transport.verifySession(sessionId, scope);
await this.assertWriteAuthorized('session.terminate', session.id, scope, approvalRef);
await this.options.transport.terminate(session.id, approvalRef, scope);
}
private runtimeSession(session: MatrixRuntimeSession): RuntimeSession {
return { ...session, providerId: this.id };
}
private async assertRead(
operation: MatrixReadOperation,
sessionId: string | undefined,
scope: RuntimeScope,
): Promise<void> {
const allowed = await this.readAuthority.canRead({
operation,
scope,
...(sessionId ? { sessionId } : {}),
});
if (!allowed)
throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime read is not authorized');
}
private async assertWritePermitted(
operation: 'session.send' | 'session.terminate',
sessionId: string,
scope: RuntimeScope,
approvalRef?: string,
): Promise<void> {
const allowed = await this.writeAuthority.canWrite({
operation,
sessionId,
scope,
...(approvalRef ? { approvalRef } : {}),
});
if (!allowed)
throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime write is not authorized');
}
private async assertWriteAuthorized(
operation: 'session.send' | 'session.terminate',
sessionId: string,
scope: RuntimeScope,
approvalRef?: string,
): Promise<void> {
await this.writeAuthority.assertAuthorized({
operation,
sessionId,
scope,
...(approvalRef ? { approvalRef } : {}),
});
}
private pruneExpired(nowMs: number): void {
for (const [id, attachment] of this.attachments) {
if (attachment.expiresAtMs <= nowMs) this.attachments.delete(id);
}
}
}
function snapshotScope(scope: RuntimeScope): RuntimeScope {
return Object.freeze({ ...scope });
}
function sameScope(left: RuntimeScope, right: RuntimeScope): boolean {
return (
left.actorId === right.actorId &&
left.tenantId === right.tenantId &&
left.channelId === right.channelId &&
left.correlationId === right.correlationId
);
}

View File

@@ -1,176 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { AgentRuntimeProvider, RuntimeScope } from '@mosaicstack/types';
import {
MatrixNativeRuntimeProvider,
type MatrixRuntimeTransport,
} from './matrix-native-runtime-provider.js';
import {
TmuxFleetRuntimeProvider,
type FleetRuntimeTransport,
} from './tmux-fleet-runtime-provider.js';
const scope: RuntimeScope = {
actorId: 'operator-1',
tenantId: 'tenant-a',
channelId: 'cli',
correlationId: 'corr-1',
};
interface ProviderFixture {
name: string;
provider: AgentRuntimeProvider;
providerId: string;
verifySession: unknown;
send: unknown;
}
function fixtures(): ProviderFixture[] {
const fleetTransport: FleetRuntimeTransport = {
verifySession: vi.fn(async () => ({
id: 'session-1',
runtimeId: 'native-1',
socketName: 'fleet',
})),
listSessions: vi.fn(async () => [
{ id: 'session-1', runtimeId: 'native-1', socketName: 'fleet' },
]),
sendMessage: vi.fn(async () => undefined),
terminate: vi.fn(async () => undefined),
};
const fleet = new TmuxFleetRuntimeProvider({
transport: fleetTransport,
readAuthority: { canRead: vi.fn(async () => true) },
writeAuthority: {
canWrite: vi.fn(async () => true),
assertAuthorized: vi.fn(async () => undefined),
},
attachmentIdFactory: () => 'attachment-1',
now: () => new Date('2026-07-13T00:00:00.000Z'),
});
const matrixTransport: MatrixRuntimeTransport = {
health: vi.fn(async () => ({
status: 'healthy' as const,
checkedAt: '2026-07-13T00:00:00.000Z',
})),
verifySession: vi.fn(async () => ({
id: 'session-1',
runtimeId: 'native-1',
state: 'active' as const,
createdAt: '2026-07-13T00:00:00.000Z',
updatedAt: '2026-07-13T00:00:00.000Z',
})),
listSessions: vi.fn(async () => [
{
id: 'session-1',
runtimeId: 'native-1',
state: 'active' as const,
createdAt: '2026-07-13T00:00:00.000Z',
updatedAt: '2026-07-13T00:00:00.000Z',
},
]),
stream: async function* () {},
send: vi.fn(async () => undefined),
terminate: vi.fn(async () => undefined),
};
const matrix = new MatrixNativeRuntimeProvider({
transport: matrixTransport,
readAuthority: { canRead: vi.fn(async () => true) },
writeAuthority: {
canWrite: vi.fn(async () => true),
assertAuthorized: vi.fn(async () => undefined),
},
attachmentIdFactory: () => 'attachment-1',
now: () => new Date('2026-07-13T00:00:00.000Z'),
});
return [
{
name: 'tmux/fleet',
provider: fleet,
providerId: 'fleet.tmux',
verifySession: fleetTransport.verifySession,
send: fleetTransport.sendMessage,
},
{
name: 'Matrix/native',
provider: matrix,
providerId: 'runtime.matrix',
verifySession: matrixTransport.verifySession,
send: matrixTransport.send,
},
];
}
/** Shared contract tests for the migration-safe provider intersection. */
describe('tmux/fleet and Matrix/native provider parity', (): void => {
it.each(fixtures())(
'%s exposes the shared runtime operations',
async (fixture): Promise<void> => {
await expect(fixture.provider.capabilities(scope)).resolves.toEqual(
expect.objectContaining({
supported: expect.arrayContaining([
'session.list',
'session.tree',
'session.send',
'session.attach',
'session.terminate',
]),
}),
);
await expect(fixture.provider.listSessions(scope)).resolves.toEqual([
expect.objectContaining({
id: 'session-1',
providerId: fixture.providerId,
runtimeId: 'native-1',
}),
]);
},
);
it.each(fixtures())(
'%s rejects empty messages before touching its transport',
async (fixture): Promise<void> => {
await expect(
fixture.provider.sendMessage(
'session-1',
{ content: '', idempotencyKey: 'message-1' },
scope,
),
).rejects.toMatchObject({ code: 'invalid_request' });
expect(fixture.verifySession).not.toHaveBeenCalled();
expect(fixture.send).not.toHaveBeenCalled();
},
);
it.each(fixtures())(
'%s rejects an empty termination approval before touching its transport',
async (fixture): Promise<void> => {
await expect(fixture.provider.terminate('session-1', '', scope)).rejects.toMatchObject({
code: 'invalid_request',
});
expect(fixture.verifySession).not.toHaveBeenCalled();
},
);
it.each(fixtures())(
'%s creates a read-only handle bound to immutable scope',
async (fixture): Promise<void> => {
await expect(fixture.provider.attach('session-1', 'control', scope)).rejects.toMatchObject({
code: 'forbidden',
});
expect(fixture.verifySession).not.toHaveBeenCalled();
await expect(fixture.provider.attach('session-1', 'read', scope)).resolves.toEqual(
expect.objectContaining({
attachmentId: 'attachment-1',
sessionId: 'session-1',
mode: 'read',
}),
);
await expect(
fixture.provider.detach('attachment-1', { ...scope, actorId: 'operator-2' }),
).rejects.toMatchObject({ code: 'forbidden' });
},
);
});

View File

@@ -3,7 +3,7 @@ import {
DurableSessionCoordinator,
InMemoryDurableSessionStore,
type DurableSessionIdentity,
} from './durable-session.js';
} from './tess-durable-session.js';
const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova',

View File

@@ -102,7 +102,7 @@ export interface DurableSessionStore {
export class DurableSessionNotFoundError extends Error {
constructor(sessionId: string) {
super(`Durable session not found: ${sessionId}`);
super(`Durable Tess session not found: ${sessionId}`);
this.name = 'DurableSessionNotFoundError';
}
}
@@ -212,11 +212,11 @@ export class DurableSessionCoordinator {
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
const handoff = await this.store.findHandoff(handoffId);
if (!handoff) throw new Error(`Durable handoff not found: ${handoffId}`);
if (!handoff) throw new Error(`Durable Tess handoff not found: ${handoffId}`);
const snapshot = await this.snapshot(handoff.sessionId);
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
if (!checkpoint) {
throw new Error(`Durable handoff checkpoint is unavailable: ${handoff.checkpointId}`);
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${handoff.checkpointId}`);
}
return { identity: snapshot.identity, checkpoint, handoff };
}
@@ -257,7 +257,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
const existing = this.sessions.get(identity.sessionId);
if (existing) {
if (!sameEnrollmentScope(existing.identity, identity)) {
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
}
existing.identity.providerId = identity.providerId;
existing.identity.runtimeSessionId = identity.runtimeSessionId;
@@ -290,7 +290,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
const existing = state.inbox.get(input.idempotencyKey);
if (existing) {
if (!sameInbox(existing, input)) {
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: existing.status };
}
@@ -323,7 +323,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
const existing = state.outbox.get(input.idempotencyKey);
if (existing) {
if (!sameOutbox(existing, input)) {
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: existing.status };
}
@@ -364,7 +364,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
const checkpoints = this.require(input.sessionId).checkpoints;
const existing = checkpoints.get(input.checkpointId);
if (existing && !sameCheckpoint(existing, input)) {
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
}
if (!existing) checkpoints.set(input.checkpointId, { ...input });
}
@@ -377,11 +377,11 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
async handoff(input: DurableHandoffInput): Promise<void> {
const state = this.require(input.sessionId);
if (!state.checkpoints.has(input.checkpointId)) {
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
}
const existing = state.handoffs.get(input.handoffId);
if (existing && !sameHandoff(existing, input)) {
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
}
if (!existing) state.handoffs.set(input.handoffId, { ...input });
}
@@ -413,7 +413,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
kind: string,
): T {
const entry = records.get(idempotencyKey);
if (!entry) throw new Error(`Durable ${kind} entry not found: ${idempotencyKey}`);
if (!entry) throw new Error(`Durable Tess ${kind} entry not found: ${idempotencyKey}`);
return entry;
}
}

View File

@@ -202,7 +202,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
expect(fleet.terminate).not.toHaveBeenCalled();
});
it('rejects an unverified target before consulting orchestrator write authority', async (): Promise<void> => {
it('rejects an unverified target before consulting Mos write authority', async (): Promise<void> => {
const fleet = transport();
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
throw new Error('target identity mismatch');
@@ -220,20 +220,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
expect(fleet.sendMessage).not.toHaveBeenCalled();
});
it('uses the role-neutral interaction source label by default', async (): Promise<void> => {
const fleet = transport();
const writeAuthority: FleetWriteAuthority = {
canWrite: vi.fn(async (): Promise<boolean> => true),
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
};
const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority });
await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope);
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'interaction');
});
it('passes an exact session ID to the fleet transport only through orchestrator-authorized writes', async (): Promise<void> => {
it('passes an exact session ID to the fleet transport only through authorized Mos writes', async (): Promise<void> => {
const fleet = transport();
const writeAuthority: FleetWriteAuthority = {
canWrite: vi.fn(async (): Promise<boolean> => true),
@@ -241,7 +228,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
};
const provider = new TmuxFleetRuntimeProvider({
transport: fleet,
sourceLabel: 'operator',
sourceLabel: 'tess',
writeAuthority,
});
@@ -259,7 +246,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
scope,
approvalRef: 'approval-1',
});
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator');
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'tess');
expect(fleet.terminate).toHaveBeenCalledWith('coder0');
});

View File

@@ -64,14 +64,14 @@ export interface FleetWriteAuthorization {
}
/**
* The orchestrator is the only authority that may permit interaction-plane write/control requests to a
* Mos is the only authority that may permit Tess write/control requests to a
* fleet peer. Gateway records the request and denial/success around provider
* invocation; the default authority prevents direct interaction-plane writes by design.
* invocation; the default authority prevents direct Tess writes by design.
*/
export interface FleetWriteAuthority {
/** Non-consuming preflight used before probing the fleet transport. */
canWrite(authorization: FleetWriteAuthorization): Promise<boolean>;
/** Final exact-target authorization; may consume an orchestrator grant. */
/** Final exact-target authorization; may consume a Mos grant. */
assertAuthorized(authorization: FleetWriteAuthorization): Promise<void>;
}
@@ -116,7 +116,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority {
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
throw new FleetRuntimeProviderError(
'forbidden',
'Fleet writes require an explicit orchestrator authority decision',
'Fleet writes require an explicit Mos authority decision',
);
}
}
@@ -124,7 +124,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority {
/**
* A capability-limited provider for rostered local fleet peers. It never
* permits raw tmux socket/target selection, interactive control attach, or
* direct interaction-plane writes; all side effects pass through exact transport checks.
* direct Tess writes; all side effects pass through exact transport checks.
*/
export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
readonly id = FLEET_PROVIDER_ID;
@@ -139,7 +139,7 @@ export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
this.sourceLabel = options.sourceLabel ?? 'interaction';
this.sourceLabel = options.sourceLabel ?? 'tess';
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
this.now = options.now ?? (() => new Date());
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;

View File

@@ -1,154 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import {
InMemoryInteractionCoordinationPort,
InteractionCoordinationClient,
type CoordinationScope,
type InteractionCoordinationAuthorityError,
type InteractionCoordinationPort,
} from '../index.js';
const scope: CoordinationScope = {
actorId: 'operator-1',
tenantId: 'tenant-a',
correlationId: 'corr-1',
requesterAgentId: 'Nova',
};
function client(
port: InteractionCoordinationPort,
handoffIdFactory: () => string = (): string => 'handoff-1',
): InteractionCoordinationClient {
return new InteractionCoordinationClient(
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
port,
handoffIdFactory,
);
}
describe('InteractionCoordinationClient', (): void => {
it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = client(adapter);
await expect(
coordination.handoff(
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
scope,
),
).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');
await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({
status: 'completed',
targetAgentId: 'Conductor',
activity: expect.arrayContaining([
expect.objectContaining({ status: 'queued' }),
expect.objectContaining({ status: 'running' }),
expect.objectContaining({ status: 'completed' }),
]),
});
await expect(coordination.result('handoff-1', scope)).resolves.toEqual({
handoffId: 'handoff-1',
targetAgentId: 'Conductor',
status: 'completed',
correlationId: 'corr-1',
summary: 'Merged by orchestrator',
});
expect(coordination).not.toHaveProperty('dispatch');
expect(coordination).not.toHaveProperty('assign');
expect(coordination).not.toHaveProperty('review');
expect(coordination).not.toHaveProperty('merge');
expect(coordination).not.toHaveProperty('cancel');
});
it('fails closed before delivery when an unconfigured agent requests orchestrator work', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
await expect(
client(adapter).handoff(
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
{ ...scope, requesterAgentId: 'Untrusted' },
),
).rejects.toMatchObject({
code: 'requester_forbidden',
} satisfies Partial<InteractionCoordinationAuthorityError>);
});
it('rejects self-delegation configuration before constructing a client', (): void => {
expect(
(): InteractionCoordinationClient =>
new InteractionCoordinationClient(
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
new InMemoryInteractionCoordinationPort(),
),
).toThrow('Interaction and orchestration identities must differ');
});
it('rejects whitespace-equivalent self-delegation identities', (): void => {
expect(
(): InteractionCoordinationClient =>
new InteractionCoordinationClient(
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
new InMemoryInteractionCoordinationPort(),
),
).toThrow('Interaction and orchestration identities must differ');
});
it('does not expose another tenant handoff to observe or result', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = client(adapter);
await coordination.handoff(
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
scope,
);
const otherTenantScope = { ...scope, tenantId: 'tenant-b' };
await expect(coordination.observe('handoff-1', otherTenantScope)).rejects.toMatchObject({
code: 'forbidden',
});
await expect(coordination.result('handoff-1', otherTenantScope)).rejects.toMatchObject({
code: 'forbidden',
});
});
it('bounds native handoff retention by evicting the oldest handoff', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort({ maxHandoffs: 1 });
const first = client(adapter, (): string => 'handoff-1');
const second = client(adapter, (): string => 'handoff-2');
await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope);
await second.handoff({ idempotencyKey: 'handoff-request-2', summary: 'Second request' }, scope);
await expect(first.observe('handoff-1', scope)).rejects.toMatchObject({ code: 'not_found' });
await expect(second.observe('handoff-2', scope)).resolves.toMatchObject({ status: 'queued' });
});
it('fails closed when a transport reports target drift', async (): Promise<void> => {
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async () => ({
handoffId: 'handoff-1',
targetAgentId: 'Unexpected',
status: 'accepted' as const,
correlationId: 'corr-1',
})),
observe: vi.fn(),
result: vi.fn(),
};
await expect(
client(adapter).handoff(
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
scope,
),
).rejects.toMatchObject({
code: 'target_drift',
} satisfies Partial<InteractionCoordinationAuthorityError>);
});
});

View File

@@ -1,208 +0,0 @@
import {
type CoordinationObservation,
type CoordinationResult,
type CoordinationScope,
type InteractionCoordinationActivity,
type InteractionCoordinationPort,
type Handoff,
type HandoffReceipt,
type HandoffStatus,
} from './interaction-coordination.js';
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
const DEFAULT_MAX_HANDOFFS = 1_000;
interface StoredHandoff {
readonly handoff: Handoff;
status: HandoffStatus;
readonly activity: InteractionCoordinationActivity[];
readonly expiresAt: number;
result?: CoordinationResult;
}
export interface InMemoryInteractionCoordinationPortOptions {
now?: () => Date;
handoffTtlMs?: number;
maxHandoffs?: number;
}
/**
* Native deterministic queue/port adapter for the coordination boundary.
* It intentionally has no fleet/tmux dependency. A future deployment adapter
* implements InteractionCoordinationPort without changing interaction-plane callers.
*/
export class InMemoryInteractionCoordinationPort implements InteractionCoordinationPort {
private readonly handoffs = new Map<string, StoredHandoff>();
private readonly now: () => Date;
private readonly handoffTtlMs: number;
private readonly maxHandoffs: number;
constructor(options: InMemoryInteractionCoordinationPortOptions = {}) {
this.now = options.now ?? (() => new Date());
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
}
async handoff(handoff: Handoff): Promise<HandoffReceipt> {
this.pruneExpiredHandoffs();
const existing = this.handoffs.get(handoff.handoffId);
if (existing !== undefined) {
this.assertSameHandoff(existing.handoff, handoff);
return this.receipt(existing.handoff, existing.status);
}
const stored: StoredHandoff = {
handoff: snapshotHandoff(handoff),
status: 'queued',
activity: [activity('queued', 'Handoff accepted by the native coordination queue', this.now)],
expiresAt: this.now().getTime() + this.handoffTtlMs,
};
this.handoffs.set(handoff.handoffId, stored);
this.enforceHandoffLimit();
return this.receipt(stored.handoff, stored.status);
}
async observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation> {
this.pruneExpiredHandoffs();
const stored = this.requireScopedHandoff(handoffId, scope);
return {
handoffId: stored.handoff.handoffId,
targetAgentId: stored.handoff.targetAgentId,
status: stored.status,
correlationId: stored.handoff.scope.correlationId,
activity: stored.activity.map(copyActivity),
};
}
async result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult> {
this.pruneExpiredHandoffs();
const stored = this.requireScopedHandoff(handoffId, scope);
return (
stored.result ?? {
handoffId: stored.handoff.handoffId,
targetAgentId: stored.handoff.targetAgentId,
status: 'pending',
correlationId: stored.handoff.scope.correlationId,
}
);
}
/** Host-side progression seam; interaction clients never receive this capability. */
recordActivity(handoffId: string, status: HandoffStatus, summary: string): void {
this.pruneExpiredHandoffs();
const stored = this.requireHandoff(handoffId);
stored.status = status;
stored.activity.push(activity(status, summary, this.now));
}
/** Host-side result seam for deterministic qualification; not an orchestrator consumer. */
recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void {
this.pruneExpiredHandoffs();
const stored = this.requireHandoff(handoffId);
stored.status = status;
stored.activity.push(activity(status, summary, this.now));
stored.result = {
handoffId: stored.handoff.handoffId,
targetAgentId: stored.handoff.targetAgentId,
status,
correlationId: stored.handoff.scope.correlationId,
summary,
};
}
private receipt(handoff: Handoff, status: HandoffStatus): HandoffReceipt {
return {
handoffId: handoff.handoffId,
targetAgentId: handoff.targetAgentId,
status: status === 'accepted' ? 'accepted' : 'queued',
correlationId: handoff.scope.correlationId,
};
}
private pruneExpiredHandoffs(): void {
const nowMs = this.now().getTime();
for (const [handoffId, handoff] of this.handoffs) {
if (handoff.expiresAt <= nowMs) this.handoffs.delete(handoffId);
}
}
private enforceHandoffLimit(): void {
while (this.handoffs.size > this.maxHandoffs) {
const oldest = this.handoffs.keys().next().value;
if (typeof oldest !== 'string') return;
this.handoffs.delete(oldest);
}
}
private requireScopedHandoff(handoffId: string, scope: CoordinationScope): StoredHandoff {
const stored = this.requireHandoff(handoffId);
if (
stored.handoff.scope.tenantId !== scope.tenantId ||
stored.handoff.scope.actorId !== scope.actorId ||
stored.handoff.scope.requesterAgentId !== scope.requesterAgentId
) {
throw new InMemoryInteractionCoordinationError('forbidden', 'Handoff scope does not match');
}
return stored;
}
private requireHandoff(handoffId: string): StoredHandoff {
const stored = this.handoffs.get(handoffId);
if (stored === undefined) {
throw new InMemoryInteractionCoordinationError('not_found', 'Handoff was not found');
}
return stored;
}
private assertSameHandoff(existing: Handoff, incoming: Handoff): void {
if (
existing.targetAgentId !== incoming.targetAgentId ||
existing.request.idempotencyKey !== incoming.request.idempotencyKey ||
existing.request.summary !== incoming.request.summary ||
existing.request.context !== incoming.request.context ||
existing.request.missionId !== incoming.request.missionId ||
existing.scope.actorId !== incoming.scope.actorId ||
existing.scope.tenantId !== incoming.scope.tenantId ||
existing.scope.correlationId !== incoming.scope.correlationId ||
existing.scope.requesterAgentId !== incoming.scope.requesterAgentId
) {
throw new InMemoryInteractionCoordinationError(
'conflict',
'Handoff ID is already bound to different immutable input',
);
}
}
}
export type InMemoryInteractionCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
export class InMemoryInteractionCoordinationError extends Error {
constructor(
readonly code: InMemoryInteractionCoordinationErrorCode,
message: string,
) {
super(message);
this.name = InMemoryInteractionCoordinationError.name;
}
}
function activity(
status: HandoffStatus,
summary: string,
now: () => Date,
): InteractionCoordinationActivity {
return { occurredAt: now().toISOString(), status, summary };
}
function copyActivity(entry: InteractionCoordinationActivity): InteractionCoordinationActivity {
return { ...entry };
}
function snapshotHandoff(handoff: Handoff): Handoff {
return Object.freeze({
handoffId: handoff.handoffId,
targetAgentId: handoff.targetAgentId,
request: Object.freeze({ ...handoff.request }),
scope: Object.freeze({ ...handoff.scope }),
});
}

View File

@@ -2,26 +2,6 @@ export { createMission, loadMission, missionFilePath, saveMission } from './miss
export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js';
export { runTask, resumeTask } from './runner.js';
export { getMissionStatus, getTaskStatus } from './status.js';
export {
InMemoryInteractionCoordinationError,
InMemoryInteractionCoordinationPort,
} from './in-memory-interaction-coordination-port.js';
export {
InteractionCoordinationAuthorityError,
InteractionCoordinationClient,
} from './interaction-coordination.js';
export type {
CoordinationObservation,
CoordinationResult,
CoordinationScope,
InteractionCoordinationActivity,
InteractionCoordinationIdentity,
InteractionCoordinationPort,
Handoff,
HandoffReceipt,
HandoffRequest,
HandoffStatus,
} from './interaction-coordination.js';
export type {
CreateMissionOptions,
Mission,

View File

@@ -1,209 +0,0 @@
export type HandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
export interface CoordinationScope {
readonly actorId: string;
readonly tenantId: string;
readonly correlationId: string;
/** Trusted gateway/configuration identity; never supplied by a channel client. */
readonly requesterAgentId: string;
}
export interface InteractionCoordinationIdentity {
readonly interactionAgentId: string;
readonly orchestrationAgentId: string;
}
export interface HandoffRequest {
readonly idempotencyKey: string;
readonly summary: string;
readonly context?: string;
readonly missionId?: string;
}
export interface Handoff {
readonly handoffId: string;
readonly targetAgentId: string;
readonly request: HandoffRequest;
readonly scope: CoordinationScope;
}
export interface HandoffReceipt {
readonly handoffId: string;
readonly targetAgentId: string;
readonly status: 'queued' | 'accepted';
readonly correlationId: string;
}
export interface InteractionCoordinationActivity {
readonly occurredAt: string;
readonly status: HandoffStatus;
readonly summary: string;
}
export interface CoordinationObservation {
readonly handoffId: string;
readonly targetAgentId: string;
readonly status: HandoffStatus;
readonly correlationId: string;
readonly activity: readonly InteractionCoordinationActivity[];
}
export interface CoordinationResult {
readonly handoffId: string;
readonly targetAgentId: string;
readonly status: 'completed' | 'failed' | 'pending';
readonly correlationId: string;
readonly summary?: string;
}
/**
* Transport-neutral boundary. The interaction plane can request work and read
* its progress/result, but it cannot issue worker, review, merge, or other
* general orchestration commands.
*/
export interface InteractionCoordinationPort {
handoff(handoff: Handoff): Promise<HandoffReceipt>;
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
}
export type InteractionCoordinationAuthorityErrorCode =
| 'invalid_identity'
| 'requester_forbidden'
| 'target_drift'
| 'correlation_drift';
export class InteractionCoordinationAuthorityError extends Error {
constructor(
readonly code: InteractionCoordinationAuthorityErrorCode,
message: string,
) {
super(message);
this.name = InteractionCoordinationAuthorityError.name;
}
}
/**
* Enforces the interaction-to-orchestration authority boundary before a
* transport is reached. Identity names remain configuration data.
*/
export class InteractionCoordinationClient {
private readonly identity: InteractionCoordinationIdentity;
constructor(
identity: InteractionCoordinationIdentity,
private readonly port: InteractionCoordinationPort,
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {
this.identity = normalizeIdentity(identity);
}
async handoff(request: HandoffRequest, scope: CoordinationScope): Promise<HandoffReceipt> {
this.assertRequester(scope);
const handoff: Handoff = {
handoffId: this.handoffIdFactory(),
targetAgentId: this.identity.orchestrationAgentId,
request: snapshotRequest(request),
scope: snapshotScope(scope),
};
const receipt = await this.port.handoff(handoff);
if (
receipt.handoffId !== handoff.handoffId ||
receipt.targetAgentId !== handoff.targetAgentId
) {
throw new InteractionCoordinationAuthorityError(
'target_drift',
'Interaction coordination transport returned a mismatched handoff target',
);
}
if (receipt.correlationId !== handoff.scope.correlationId) {
throw new InteractionCoordinationAuthorityError(
'correlation_drift',
'Interaction coordination transport returned a mismatched correlation ID',
);
}
return receipt;
}
async observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation> {
this.assertRequester(scope);
return this.assertObservation(await this.port.observe(handoffId, snapshotScope(scope)), scope);
}
async result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult> {
this.assertRequester(scope);
return this.assertResult(await this.port.result(handoffId, snapshotScope(scope)), scope);
}
private assertRequester(scope: CoordinationScope): void {
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
throw new InteractionCoordinationAuthorityError(
'requester_forbidden',
'Requester is not the configured interaction agent',
);
}
}
private assertObservation(
observation: CoordinationObservation,
scope: CoordinationScope,
): CoordinationObservation {
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
throw new InteractionCoordinationAuthorityError(
'target_drift',
'Interaction coordination transport returned an unexpected observation target',
);
}
if (observation.correlationId !== scope.correlationId) {
throw new InteractionCoordinationAuthorityError(
'correlation_drift',
'Interaction coordination transport returned a mismatched observation correlation ID',
);
}
return observation;
}
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
throw new InteractionCoordinationAuthorityError(
'target_drift',
'Interaction coordination transport returned an unexpected result target',
);
}
if (result.correlationId !== scope.correlationId) {
throw new InteractionCoordinationAuthorityError(
'correlation_drift',
'Interaction coordination transport returned a mismatched result correlation ID',
);
}
return result;
}
}
function normalizeIdentity(
identity: InteractionCoordinationIdentity,
): InteractionCoordinationIdentity {
const interactionAgentId = identity.interactionAgentId.trim();
const orchestrationAgentId = identity.orchestrationAgentId.trim();
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
throw new InteractionCoordinationAuthorityError(
'invalid_identity',
'Interaction and orchestration identities are required',
);
}
if (interactionAgentId === orchestrationAgentId) {
throw new InteractionCoordinationAuthorityError(
'invalid_identity',
'Interaction and orchestration identities must differ',
);
}
return Object.freeze({ interactionAgentId, orchestrationAgentId });
}
function snapshotRequest(request: HandoffRequest): HandoffRequest {
return Object.freeze({ ...request });
}
function snapshotScope(scope: CoordinationScope): CoordinationScope {
return Object.freeze({ ...scope });
}

View File

@@ -9,8 +9,7 @@ export type RuntimeAuditOperation =
| 'session.attach'
| 'session.terminate'
| 'runtime.capabilities'
| 'runtime.health'
| 'runtime.transitional-capabilities';
| 'runtime.health';
export type RuntimeAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
export type RuntimeAuditErrorCode = 'policy_denied' | 'provider_error';

View File

@@ -274,20 +274,6 @@ describe('KeywordAdapter', () => {
expect(results).toHaveLength(1);
});
it('should return all scoped insights for the explicit wildcard query', async () => {
await adapter.storeInsight({
userId: 'u1',
content: 'A literal * marker is still ordinary content',
source: 'chat',
category: 'technical',
relevanceScore: 0.7,
});
const results = await adapter.searchInsights('u1', '*');
expect(results).toHaveLength(4);
expect(results.every((result) => result.score === 1)).toBe(true);
});
it('should return empty for empty query', async () => {
const results = await adapter.searchInsights('u1', ' ');
expect(results).toHaveLength(0);

View File

@@ -132,23 +132,19 @@ export class KeywordAdapter implements MemoryAdapter {
opts?: { limit?: number; embedding?: number[] },
): Promise<InsightSearchResult[]> {
const limit = opts?.limit ?? 10;
const normalizedQuery = query.trim();
const matchAll = normalizedQuery === '*';
const words = matchAll
? []
: normalizedQuery
.toLowerCase()
.split(/\s+/)
.filter((word) => word.length > 0);
const words = query
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 0);
if (words.length === 0 && !matchAll) return [];
if (words.length === 0) return [];
const rows = await this.storage.find<InsightRecord>(INSIGHTS, { userId });
const scored: InsightSearchResult[] = [];
for (const row of rows) {
const content = row.content.toLowerCase();
let score = matchAll ? 1 : 0;
let score = 0;
for (const word of words) {
if (content.includes(word)) score++;
}

View File

@@ -22,13 +22,6 @@ export type {
InsightSearchResult,
} from './types.js';
export { createMemoryAdapter, registerMemoryAdapter } from './factory.js';
export {
createOperatorMemoryPlugin,
type OperatorMemoryPlugin,
type OperatorMemoryScope,
type OperatorMemoryConfig,
type OperatorMemoryResult,
} from './operator-memory-plugin.js';
export { PgVectorAdapter } from './adapters/pgvector.js';
export { KeywordAdapter } from './adapters/keyword.js';

View File

@@ -1,147 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { createOperatorMemoryPlugin } from './operator-memory-plugin.js';
import type { Insight, InsightSearchResult, NewInsight } from './types.js';
function adapter() {
return {
name: 'test',
embedder: null,
storeInsight: vi.fn(
async (value: NewInsight): Promise<Insight> => ({
...value,
id: '1',
createdAt: new Date(),
}),
),
searchInsights: vi.fn(async (): Promise<InsightSearchResult[]> => []),
getInsight: vi.fn(),
deleteInsight: vi.fn(),
getPreference: vi.fn(),
setPreference: vi.fn(),
deletePreference: vi.fn(),
listPreferences: vi.fn(),
close: vi.fn(),
};
}
describe('OperatorMemoryPlugin', () => {
it('isolates configured namespace storage across server-derived tenant, owner, and session scopes', async () => {
const memory = adapter();
const plugin = createOperatorMemoryPlugin({
adapter: memory,
instanceId: 'Nova',
namespace: 'operator-memory',
redact: (value) => value,
});
await plugin.capture(
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' },
{ content: 'one', source: 'test', category: 'note' },
);
await plugin.capture(
{ tenantId: 'tenant-b', ownerId: 'owner-a', sessionId: 'session-a' },
{ content: 'two', source: 'test', category: 'note' },
);
await plugin.capture(
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-b' },
{ content: 'three', source: 'test', category: 'note' },
);
expect(
memory.storeInsight.mock.calls.map((call: unknown[]) => (call[0] as NewInsight).userId),
).toEqual([
'["operator-memory","tenant-a","owner-a","session-a"]',
'["operator-memory","tenant-b","owner-a","session-a"]',
'["operator-memory","tenant-a","owner-a","session-b"]',
]);
});
it('rejects an incomplete runtime scope before it can produce a shared storage key', async () => {
const memory = adapter();
const plugin = createOperatorMemoryPlugin({
adapter: memory,
instanceId: 'Nova',
namespace: 'operator-memory',
redact: (value) => value,
});
await expect(
plugin.capture(
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: ' ' },
{ content: 'note', source: 'test', category: 'note' },
),
).rejects.toThrow('Operator memory session ID is required');
expect(memory.storeInsight).not.toHaveBeenCalled();
});
it('uses a differently named configured instance in retrieval provenance', async () => {
const memory = adapter();
memory.searchInsights.mockResolvedValue([
{ id: '1', content: 'x', score: 1, metadata: { source: 'project' } },
]);
const plugin = createOperatorMemoryPlugin({
adapter: memory,
instanceId: 'Nova',
namespace: 'operator-memory',
redact: (value) => value,
});
const results = await plugin.search({ tenantId: 't', ownerId: 'o', sessionId: 's' }, 'x');
expect(results[0]?.provenance).toEqual({
instanceId: 'Nova',
namespace: 'operator-memory',
source: 'project',
});
});
it('orders startup context with project and flat-file truth before retrieved material', async () => {
const memory = adapter();
memory.searchInsights.mockResolvedValue([
{ id: 'retrieval', content: 'retrieval', score: 1 },
{ id: 'flat-file', content: 'flat-file', score: 1, metadata: { source: 'flat-file' } },
{ id: 'project', content: 'project', score: 1, metadata: { source: 'project' } },
]);
const plugin = createOperatorMemoryPlugin({
adapter: memory,
instanceId: 'Nova',
namespace: 'operator-memory',
maxStartupContext: 2,
redact: (value) => value,
});
const context = await plugin.startupContext({
tenantId: 'tenant-a',
ownerId: 'owner-a',
sessionId: 'session-a',
});
expect(context.map((result) => result.id)).toEqual(['project', 'flat-file']);
expect(memory.searchInsights).toHaveBeenCalledWith(expect.any(String), '*', { limit: 64 });
});
it('redacts content before adapter persistence and records configured provenance metadata', async () => {
const memory = adapter();
const plugin = createOperatorMemoryPlugin({
adapter: memory,
instanceId: 'Nova',
namespace: 'operator-memory',
redact: (value) => value.replace('secret', '[REDACTED]'),
});
await plugin.capture(
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' },
{ content: 'secret note', source: 'project', category: 'note' },
);
expect(memory.storeInsight).toHaveBeenCalledWith(
expect.objectContaining({
content: '[REDACTED] note',
metadata: {
instanceId: 'Nova',
namespace: 'operator-memory',
source: 'project',
},
}),
);
});
});

View File

@@ -1,154 +0,0 @@
import type { Insight, InsightSearchResult, MemoryAdapter } from './types.js';
const STARTUP_CONTEXT_CANDIDATE_LIMIT = 64;
/** Immutable server-derived boundary; callers never choose an adapter namespace. */
export interface OperatorMemoryScope {
readonly tenantId: string;
readonly ownerId: string;
readonly sessionId: string;
}
export interface OperatorMemoryConfig {
/** Adapter injection is deployment/lifecycle configuration, never caller input. */
readonly adapter: MemoryAdapter;
/** Configured agent identity; it is metadata rather than a storage key default. */
readonly instanceId: string;
/** Configured storage partition; callers cannot select a namespace. */
readonly namespace: string;
readonly maxStartupContext?: number;
redact(content: string): string;
}
export interface OperatorMemoryResult extends InsightSearchResult {
provenance: { instanceId: string; namespace: string; source: string };
}
export interface OperatorMemoryPlugin {
capture(
scope: OperatorMemoryScope,
input: { content: string; source: string; category: string },
): Promise<Insight>;
search(
scope: OperatorMemoryScope,
query: string,
limit?: number,
): Promise<OperatorMemoryResult[]>;
recent(scope: OperatorMemoryScope, limit?: number): Promise<OperatorMemoryResult[]>;
stats(scope: OperatorMemoryScope): Promise<{ namespace: string; resultCount: number }>;
startupContext(scope: OperatorMemoryScope): Promise<OperatorMemoryResult[]>;
}
function scopedUserId(scope: OperatorMemoryScope, namespace: string): string {
const normalizedScope = normalizeScope(scope);
// JSON tuple encoding avoids delimiter collisions between independently scoped IDs.
return JSON.stringify([
namespace,
normalizedScope.tenantId,
normalizedScope.ownerId,
normalizedScope.sessionId,
]);
}
function normalizeScope(scope: OperatorMemoryScope): OperatorMemoryScope {
if (typeof scope !== 'object' || scope === null) {
throw new Error('Operator memory scope is required');
}
return Object.freeze({
tenantId: requiredScopeId(scope.tenantId, 'tenant ID'),
ownerId: requiredScopeId(scope.ownerId, 'owner ID'),
sessionId: requiredScopeId(scope.sessionId, 'session ID'),
});
}
function requiredScopeId(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`Operator memory ${field} is required`);
}
return value.trim();
}
function compareStartupContext(left: OperatorMemoryResult, right: OperatorMemoryResult): number {
return (
startupSourcePriority(left.provenance.source) - startupSourcePriority(right.provenance.source)
);
}
function startupSourcePriority(source: string): number {
if (source === 'project') return 0;
if (source === 'flat-file') return 1;
return 2;
}
function normalizeConfig(config: OperatorMemoryConfig): OperatorMemoryConfig {
const instanceId = config.instanceId.trim();
const namespace = config.namespace.trim();
const maxStartupContext = config.maxStartupContext ?? 8;
if (instanceId.length === 0 || namespace.length === 0) {
throw new Error('Operator memory instance ID and namespace must be configured');
}
if (!Number.isSafeInteger(maxStartupContext) || maxStartupContext < 1) {
throw new Error('Operator memory startup context limit must be a positive integer');
}
return Object.freeze({ ...config, instanceId, namespace, maxStartupContext });
}
/** Creates a leaf-package, replaceable memory adapter facade. */
export function createOperatorMemoryPlugin(config: OperatorMemoryConfig): OperatorMemoryPlugin {
const pluginConfig = normalizeConfig(config);
const mapResult = (result: InsightSearchResult): OperatorMemoryResult => ({
...result,
provenance: {
instanceId: pluginConfig.instanceId,
namespace: pluginConfig.namespace,
source: String(result.metadata?.['source'] ?? 'retrieval'),
},
});
const search = async (
scope: OperatorMemoryScope,
query: string,
limit = 10,
): Promise<OperatorMemoryResult[]> =>
(
await pluginConfig.adapter.searchInsights(
scopedUserId(scope, pluginConfig.namespace),
query,
{
limit,
},
)
).map(mapResult);
return {
async capture(scope, input) {
return pluginConfig.adapter.storeInsight({
userId: scopedUserId(scope, pluginConfig.namespace),
content: pluginConfig.redact(input.content),
source: input.source,
category: input.category,
relevanceScore: 1,
metadata: {
namespace: pluginConfig.namespace,
instanceId: pluginConfig.instanceId,
source: input.source,
},
});
},
search,
async recent(scope, limit = 10) {
return search(scope, '*', limit);
},
async stats(scope) {
return {
namespace: pluginConfig.namespace,
resultCount: (await search(scope, '*', 100)).length,
};
},
async startupContext(scope) {
const maxStartupContext = pluginConfig.maxStartupContext ?? 8;
// Prioritize authoritative sources within a bounded candidate window.
const candidateLimit = Math.max(maxStartupContext, STARTUP_CONTEXT_CANDIDATE_LIMIT);
const context = await search(scope, '*', candidateLimit);
return [...context].sort(compareStartupContext).slice(0, maxStartupContext);
},
};
}

View File

@@ -49,10 +49,6 @@ export interface MemoryAdapter {
// Insights
storeInsight(insight: NewInsight): Promise<Insight>;
getInsight(id: string): Promise<Insight | null>;
/**
* Searches within one scoped user ID. The reserved `*` query returns scoped
* recent/all results rather than performing backend-specific wildcard parsing.
*/
searchInsights(
userId: string,
query: string,

View File

@@ -2,10 +2,10 @@
The **operator-interaction** role is the authorized human interaction plane for
Mosaic. It presents runtime and fleet state, mediates approved actions, and
hands coding or general orchestration work to the orchestrator.
hands coding or general orchestration work to Mos.
## Boundaries
- It does not claim orchestrator-owned coding or general orchestration work.
- It does not claim Mos-owned coding or general orchestration work.
- It exposes only the configured, observable tool policy.
- It does not receive or surface credentials in its effective policy.

View File

@@ -75,14 +75,6 @@
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+$"
},
"alias": {
"description": "Optional operator-defined display name for the agent.",
"type": "string"
},
"provider": {
"description": "Optional agent runtime provider identifier such as openai-codex.",
"type": "string"
},
"runtime": {
"type": "string"
},

View File

@@ -189,36 +189,6 @@ describe('fleet roster parsing', () => {
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
});
it('accepts optional agent alias and provider metadata without requiring them', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: interaction',
' alias: Friday',
' provider: openai-codex',
' runtime: pi',
' - name: worker0',
' runtime: codex',
].join('\n'),
);
const roster = await loadFleetRoster(rosterPath);
expect(getRosterAgent(roster, 'interaction')).toMatchObject({
name: 'interaction',
alias: 'Friday',
provider: 'openai-codex',
});
expect(getRosterAgent(roster, 'worker0')).toMatchObject({ name: 'worker0' });
expect(getRosterAgent(roster, 'worker0').alias).toBeUndefined();
expect(getRosterAgent(roster, 'worker0').provider).toBeUndefined();
});
it('socketArgs: named socket → -L <name>; empty → no -L (default socket)', () => {
expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']);
expect(socketArgs('')).toEqual([]);
@@ -1902,61 +1872,7 @@ describe('fleet ps — tenant and host', () => {
});
});
describe('fleet ps — aliases and JSON output shape (FR-6)', () => {
it('shows an agent alias in table output and falls back to name when absent', async () => {
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: interaction',
' alias: Friday',
' runtime: pi',
' - name: worker0',
' runtime: codex',
].join('\n'),
);
const runner: CommandRunner = async (command, args) => {
const fullArgs = [command, ...args].join(' ');
if (fullArgs.includes('list-sessions')) {
return { stdout: 'interaction\nworker0\n', stderr: '', exitCode: 0 };
}
return {
stdout: fullArgs.includes('systemctl')
? 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n'
: '12345 pi 0 0 0 0\n',
stderr: '',
exitCode: 0,
};
};
const lines: string[] = [];
const origLog = console.log;
console.log = (msg: string) => {
lines.push(msg);
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
} finally {
console.log = origLog;
await rm(home, { recursive: true, force: true });
}
const output = lines.join('\n');
expect(output).toContain('Friday');
expect(output).toContain('worker0');
});
describe('fleet ps — JSON output shape (FR-6)', () => {
it('produces --json records including tenant_id and host for each agent', async () => {
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(home, 'fleet', 'roster.yaml');

View File

@@ -81,8 +81,6 @@ interface RawFleetRoster {
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
agents?: Array<{
name?: unknown;
alias?: unknown;
provider?: unknown;
runtime?: unknown;
class?: unknown;
working_directory?: unknown;
@@ -104,8 +102,6 @@ interface RawFleetRoster {
export interface FleetAgent {
name: string;
alias?: string;
provider?: string;
runtime: string;
className: string;
workingDirectory?: string;
@@ -1078,7 +1074,6 @@ export interface HeartbeatInfo {
export interface AgentPsRow {
name: string;
alias?: string;
tenant_id: string;
host: string;
runtime: string;
@@ -1760,7 +1755,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
rows.push({
name: agent.name,
alias: agent.alias,
tenant_id,
host,
runtime: agent.runtime,
@@ -1894,7 +1888,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
console.log(
[
(row.alias ?? row.name).padEnd(18),
row.name.padEnd(18),
row.tenant_id.padEnd(12),
row.host.padEnd(12),
row.runtime.padEnd(10),
@@ -2500,8 +2494,6 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
assertObject(raw, 'Fleet roster agent');
assertKnownKeys(raw, 'Fleet roster agent', [
'name',
'alias',
'provider',
'runtime',
'class',
'working_directory',
@@ -2533,8 +2525,6 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
}
return {
name,
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
runtime,
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
workingDirectory: optionalString(
@@ -2837,12 +2827,6 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
runtime: agent.runtime,
class: agent.className,
};
if (agent.alias !== undefined) {
raw['alias'] = agent.alias;
}
if (agent.provider !== undefined) {
raw['provider'] = agent.provider;
}
if (agent.workingDirectory !== undefined) {
raw['working_directory'] = agent.workingDirectory;
}

View File

@@ -414,7 +414,7 @@ function readFleetToolPolicyBlock(policy: string | undefined): string {
'',
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
'Delegate orchestrator-owned work through the authorized handoff boundary.',
'Delegate Mos-owned work through the authorized handoff boundary.',
].join('\n');
}

View File

@@ -1,182 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { RuntimeScope } from '@mosaicstack/types';
import {
MatrixNativeRuntimeTransport,
type MatrixFetchLike,
} from './matrix-native-runtime-transport.js';
const scope: RuntimeScope = {
actorId: 'operator-1',
tenantId: 'tenant-a',
channelId: 'cli',
correlationId: 'corr-1',
};
const bindings = [
{
id: 'native-1',
runtimeId: '@native-worker:example.test',
roomId: '!room:example.test',
remoteUserId: '@native-worker:example.test',
state: 'active' as const,
createdAt: '2026-07-13T00:00:00.000Z',
updatedAt: '2026-07-13T00:00:00.000Z',
},
];
function response(
status: number,
body: unknown = {},
): ReturnType<MatrixFetchLike> extends Promise<infer T> ? T : never {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
} as never;
}
function transport(fetchImpl: MatrixFetchLike): MatrixNativeRuntimeTransport {
return new MatrixNativeRuntimeTransport({
homeserverUrl: 'https://matrix.example.test/base',
accessToken: 'test-token',
userId: '@mosaic:example.test',
sessions: bindings,
fetchImpl,
});
}
describe('MatrixNativeRuntimeTransport', (): void => {
it('uses only configured session-to-room bindings and idempotency-derived Matrix transactions', async (): Promise<void> => {
const fetchImpl = vi
.fn<MatrixFetchLike>()
.mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' }))
.mockResolvedValueOnce(response(200, { event_id: '$sent' }));
const matrix = transport(fetchImpl);
await matrix.send('native-1', { content: 'hello', idempotencyKey: 'message-1' }, scope);
expect(fetchImpl).toHaveBeenNthCalledWith(
1,
'https://matrix.example.test/base/_matrix/client/v3/account/whoami',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
}),
);
const [url, init] = fetchImpl.mock.calls[1]!;
expect(url).toMatch(
/^https:\/\/matrix\.example\.test\/base\/_matrix\/client\/v3\/rooms\/!room%3Aexample\.test\/send\/m\.room\.message\/mosaic-send-[A-Za-z0-9_-]+$/,
);
expect(init?.method).toBe('PUT');
expect(JSON.parse(init?.body ?? '')).toEqual({
msgtype: 'm.text',
body: 'hello',
'mosaic.runtime.v1': {
session_id: 'native-1',
runtime_id: '@native-worker:example.test',
actor_id: 'operator-1',
tenant_id: 'tenant-a',
channel_id: 'cli',
correlation_id: 'corr-1',
idempotency_key: 'message-1',
},
});
});
it('sends a termination only to the bound room with the exact approval reference', async (): Promise<void> => {
const fetchImpl = vi
.fn<MatrixFetchLike>()
.mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' }))
.mockResolvedValueOnce(response(200, { event_id: '$terminated' }));
const matrix = transport(fetchImpl);
await matrix.terminate('native-1', 'approval-1', scope);
const [url, init] = fetchImpl.mock.calls[1]!;
expect(url).toMatch(
/^https:\/\/matrix\.example\.test\/base\/_matrix\/client\/v3\/rooms\/!room%3Aexample\.test\/send\/mosaic\.runtime\.terminate\/mosaic-terminate-[A-Za-z0-9_-]+$/,
);
expect(JSON.parse(init?.body ?? '')).toEqual({
session_id: 'native-1',
runtime_id: '@native-worker:example.test',
actor_id: 'operator-1',
tenant_id: 'tenant-a',
channel_id: 'cli',
correlation_id: 'corr-1',
approval_ref: 'approval-1',
});
});
it('fails closed when Matrix whoami does not match the configured native identity', async (): Promise<void> => {
const fetchImpl = vi
.fn<MatrixFetchLike>()
.mockResolvedValue(response(200, { user_id: '@other:example.test' }));
const matrix = transport(fetchImpl);
await expect(matrix.listSessions(scope)).rejects.toMatchObject({ code: 'forbidden' });
});
it('rejects unbound session IDs without using caller-supplied Matrix room data', async (): Promise<void> => {
const fetchImpl = vi
.fn<MatrixFetchLike>()
.mockResolvedValue(response(200, { user_id: '@mosaic:example.test' }));
const matrix = transport(fetchImpl);
await expect(matrix.verifySession('!attacker-room:example.test', scope)).rejects.toMatchObject({
code: 'not_found',
});
});
it('maps replay-cursor events from only the configured remote identity', async (): Promise<void> => {
const fetchImpl = vi
.fn<MatrixFetchLike>()
.mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' }))
.mockResolvedValueOnce(
response(200, {
next_batch: 'next-cursor',
rooms: {
join: {
'!room:example.test': {
timeline: {
events: [
{
type: 'mosaic.runtime.event',
sender: '@intruder:example.test',
content: { 'mosaic.runtime.v1': { type: 'message.delta', content: 'nope' } },
},
{
type: 'mosaic.runtime.event',
sender: '@native-worker:example.test',
origin_server_ts: 1_784_246_400_000,
content: {
'mosaic.runtime.v1': {
session_id: 'native-1',
type: 'message.delta',
content: 'accepted',
},
},
},
],
},
},
},
},
}),
);
const matrix = transport(fetchImpl);
const events = [];
for await (const event of matrix.stream('native-1', 'prior-cursor', scope)) events.push(event);
expect(events).toEqual([
{
type: 'message.delta',
sessionId: 'native-1',
cursor: 'next-cursor',
occurredAt: '2026-07-17T00:00:00.000Z',
content: 'accepted',
},
]);
expect(fetchImpl.mock.calls[1]?.[0]).toContain('since=prior-cursor');
});
});

View File

@@ -1,367 +0,0 @@
import { createHash } from 'node:crypto';
import type {
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSessionState,
RuntimeStreamEvent,
} from '@mosaicstack/types';
export type MatrixRuntimeTransportErrorCode =
| 'forbidden'
| 'invalid_request'
| 'not_found'
| 'unavailable';
export class MatrixRuntimeTransportError extends Error {
constructor(
readonly code: MatrixRuntimeTransportErrorCode,
message: string,
) {
super(message);
this.name = MatrixRuntimeTransportError.name;
}
}
/** Minimal injectable Matrix fetch surface; it avoids coupling this transport to an SDK. */
export interface MatrixFetchLike {
(
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string },
): Promise<{
ok: boolean;
status: number;
json(): Promise<unknown>;
text(): Promise<string>;
}>;
}
/** A server-configured runtime session binding. Callers never select a Matrix room. */
export interface MatrixNativeRuntimeSessionBinding {
id: string;
runtimeId: string;
roomId: string;
remoteUserId: string;
parentSessionId?: string;
state: RuntimeSessionState;
createdAt: string;
updatedAt: string;
}
export interface MatrixNativeRuntimeTransportOptions {
homeserverUrl: string;
accessToken: string;
/** Matrix user authenticated by the service token. */
userId: string;
sessions: readonly MatrixNativeRuntimeSessionBinding[];
fetchImpl?: MatrixFetchLike;
now?: () => Date;
}
interface MatrixSyncEvent {
type?: string;
sender?: string;
origin_server_ts?: number;
content?: Record<string, unknown>;
}
interface MatrixSyncResponse {
next_batch?: string;
rooms?: { join?: Record<string, { timeline?: { events?: MatrixSyncEvent[] } }> };
}
/**
* Concrete Matrix CS-API transport for the native provider. It authenticates
* the configured sender before each operation and resolves rooms exclusively
* from configured bindings, preventing client-selected room/identity routing.
*/
export class MatrixNativeRuntimeTransport {
private readonly baseUrl: string;
private readonly fetchImpl: MatrixFetchLike;
private readonly now: () => Date;
private readonly bindings: ReadonlyMap<string, MatrixNativeRuntimeSessionBinding>;
constructor(private readonly options: MatrixNativeRuntimeTransportOptions) {
this.baseUrl = validatedHomeserverUrl(options.homeserverUrl);
if (!options.accessToken.trim()) {
throw new MatrixRuntimeTransportError('invalid_request', 'Matrix access token is required');
}
if (!options.userId.trim()) {
throw new MatrixRuntimeTransportError('invalid_request', 'Matrix user identity is required');
}
this.bindings = new Map(
options.sessions.map((binding) => [binding.id, Object.freeze({ ...binding })]),
);
if (this.bindings.size !== options.sessions.length) {
throw new MatrixRuntimeTransportError(
'invalid_request',
'Matrix runtime session IDs must be unique',
);
}
this.fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as MatrixFetchLike);
this.now = options.now ?? (() => new Date());
}
async health(_scope: RuntimeScope): Promise<RuntimeHealth> {
try {
const versions = await this.request('/_matrix/client/versions', { method: 'GET' });
if (!versions.ok) {
return this.healthResult('down', `versions HTTP ${versions.status}`);
}
await this.assertIdentity();
return this.healthResult('healthy');
} catch (error: unknown) {
return this.healthResult('down', message(error));
}
}
async listSessions(scope: RuntimeScope): Promise<MatrixNativeRuntimeSessionBinding[]> {
await this.assertIdentity();
void scope;
return [...this.bindings.values()].map((binding) => ({ ...binding }));
}
async verifySession(
sessionId: string,
scope: RuntimeScope,
): Promise<MatrixNativeRuntimeSessionBinding> {
await this.assertIdentity();
void scope;
const binding = this.bindings.get(sessionId);
if (!binding) {
throw new MatrixRuntimeTransportError(
'not_found',
'Matrix runtime session is not configured',
);
}
return { ...binding };
}
async *stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
const binding = await this.verifySession(sessionId, scope);
const query = new URLSearchParams({ timeout: '0' });
if (cursor?.trim()) query.set('since', cursor);
const response = await this.request(`/_matrix/client/v3/sync?${query.toString()}`, {
method: 'GET',
headers: this.authHeaders(),
});
if (!response.ok) {
throw new MatrixRuntimeTransportError(
'unavailable',
`Matrix sync failed: HTTP ${response.status}`,
);
}
const payload = (await response.json()) as MatrixSyncResponse;
const nextCursor = payload.next_batch?.trim() || cursor?.trim() || 'initial';
const events = payload.rooms?.join?.[binding.roomId]?.timeline?.events ?? [];
for (const event of events) {
const normalized = normalizeEvent(event, binding, nextCursor);
if (normalized) yield normalized;
}
}
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
const binding = await this.verifySession(sessionId, scope);
const txnId = transactionId('send', binding.id, message.idempotencyKey);
const response = await this.request(
`/_matrix/client/v3/rooms/${encodeURIComponent(binding.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
{
method: 'PUT',
headers: this.authHeaders(),
body: JSON.stringify({
msgtype: 'm.text',
body: message.content,
'mosaic.runtime.v1': metadata(binding, scope, message.idempotencyKey),
}),
},
);
if (!response.ok) {
throw new MatrixRuntimeTransportError(
'unavailable',
`Matrix message delivery failed: HTTP ${response.status}`,
);
}
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
const binding = await this.verifySession(sessionId, scope);
const txnId = transactionId('terminate', binding.id, `${approvalRef}:${scope.correlationId}`);
const response = await this.request(
`/_matrix/client/v3/rooms/${encodeURIComponent(binding.roomId)}/send/mosaic.runtime.terminate/${encodeURIComponent(txnId)}`,
{
method: 'PUT',
headers: this.authHeaders(),
body: JSON.stringify({
...metadata(binding, scope),
approval_ref: approvalRef,
}),
},
);
if (!response.ok) {
throw new MatrixRuntimeTransportError(
'unavailable',
`Matrix termination delivery failed: HTTP ${response.status}`,
);
}
}
private async assertIdentity(): Promise<void> {
let response: Awaited<ReturnType<MatrixFetchLike>>;
try {
response = await this.request('/_matrix/client/v3/account/whoami', {
method: 'GET',
headers: this.authHeaders(),
});
} catch (error: unknown) {
throw new MatrixRuntimeTransportError('unavailable', message(error));
}
if (!response.ok) {
throw new MatrixRuntimeTransportError(
'forbidden',
`Matrix whoami failed: HTTP ${response.status}`,
);
}
const body = (await response.json()) as { user_id?: unknown };
if (body.user_id !== this.options.userId) {
throw new MatrixRuntimeTransportError(
'forbidden',
'Matrix whoami identity does not match configuration',
);
}
}
private request(
path: string,
init: { method?: string; headers?: Record<string, string>; body?: string },
) {
return this.fetchImpl(`${this.baseUrl}${path}`, init);
}
private authHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.options.accessToken}`,
'Content-Type': 'application/json',
};
}
private healthResult(status: RuntimeHealth['status'], detail?: string): RuntimeHealth {
return { status, checkedAt: this.now().toISOString(), ...(detail ? { detail } : {}) };
}
}
function metadata(
binding: MatrixNativeRuntimeSessionBinding,
scope: RuntimeScope,
idempotencyKey?: string,
): Record<string, string> {
return {
session_id: binding.id,
runtime_id: binding.runtimeId,
actor_id: scope.actorId,
tenant_id: scope.tenantId,
channel_id: scope.channelId,
correlation_id: scope.correlationId,
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
};
}
function transactionId(kind: 'send' | 'terminate', sessionId: string, identity: string): string {
return `mosaic-${kind}-${createHash('sha256').update(`${sessionId}\u0000${identity}`).digest('base64url')}`;
}
function normalizeEvent(
event: MatrixSyncEvent,
binding: MatrixNativeRuntimeSessionBinding,
cursor: string,
): RuntimeStreamEvent | undefined {
if (event.type !== 'mosaic.runtime.event' || event.sender !== binding.remoteUserId)
return undefined;
const payload = event.content?.['mosaic.runtime.v1'];
if (
!isRecord(payload) ||
payload['session_id'] !== binding.id ||
typeof payload['type'] !== 'string'
) {
return undefined;
}
const occurredAt =
typeof payload['occurred_at'] === 'string'
? payload['occurred_at']
: new Date(event.origin_server_ts ?? 0).toISOString();
const eventCursor = typeof payload['cursor'] === 'string' ? payload['cursor'] : cursor;
switch (payload['type']) {
case 'session.state':
return isState(payload['state'])
? {
type: 'session.state',
sessionId: binding.id,
cursor: eventCursor,
occurredAt,
state: payload['state'],
}
: undefined;
case 'message.delta':
return typeof payload['content'] === 'string'
? {
type: 'message.delta',
sessionId: binding.id,
cursor: eventCursor,
occurredAt,
content: payload['content'],
}
: undefined;
case 'message.complete':
return typeof payload['message_id'] === 'string'
? {
type: 'message.complete',
sessionId: binding.id,
cursor: eventCursor,
occurredAt,
messageId: payload['message_id'],
}
: undefined;
default:
return undefined;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isState(value: unknown): value is RuntimeSessionState {
return (
value === 'starting' ||
value === 'active' ||
value === 'idle' ||
value === 'stopped' ||
value === 'failed'
);
}
function validatedHomeserverUrl(value: string): string {
let url: URL;
try {
url = new URL(value);
} catch {
throw new MatrixRuntimeTransportError(
'invalid_request',
'Matrix homeserver URL must be absolute',
);
}
if (url.protocol !== 'https:') {
throw new MatrixRuntimeTransportError(
'invalid_request',
'Matrix homeserver URL must use HTTPS',
);
}
return url.toString().replace(/\/$/, '');
}
function message(error: unknown): string {
return error instanceof Error ? error.message : 'Matrix transport request failed';
}

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