Compare commits

..
Author SHA1 Message Date
jason.woltjeandClaude Opus 4.8 d752db93e4 fix(web): scope model list to provider and resolve composite catalog identity
ci/woodpecker/pr/ci Pipeline was successful
Fix-in-lane for the two Task-4 review findings, red-first TDD, inside the
existing nine-file fence.

M-A: the composer now filters model <option>s to the intentionally selected
provider (harness.providerId). With no provider chosen the model select offers
only its placeholder, so a user can never pick a model that belongs to a
different provider.

M-B: the model <option> value is now the collision-safe composite
`${providerId}:${modelId}` (was the bare modelId), the controlled select value
mirrors that same identity so the exact catalog row highlights, and the change
handler resolves the composite back to the exact catalog row and persists that
row's own {harnessId, providerId, modelId}. selectModel(providerId, modelId) no
longer combines a bare model id with ambient provider state, so two providers
exposing the same modelId stay distinct.

New red-first tests prove: cross-provider models absent, identical modelIds
under two providers stay distinct and resolve to the intended tuple, a provider
change invalidates the old model and keeps send disabled until the new tuple
persists, and a model pick does not enable send until its exact PUT resolves.
The four existing anti-masking invariants and the 422 tuple-preservation test
are intact.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-11 21:13:40 -05:00
jason.woltjeandClaude Opus 4.8 ef66b0e7d6 feat(web): use structured harness catalog selection
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-11 20:36:48 -05:00
311 changed files with 2368 additions and 17503 deletions
+3 -2
View File
@@ -149,5 +149,6 @@ OTEL_SERVICE_NAME=mosaic-gateway
# KEYCLOAK_CLIENT_ID=mosaic
# KEYCLOAK_CLIENT_SECRET=
# The web login page discovers configured providers dynamically from
# GET /api/sso/providers. No NEXT_PUBLIC_* provider feature flag is required.
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
# NEXT_PUBLIC_WORKOS_ENABLED=true
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true
+1 -1
View File
@@ -9,7 +9,7 @@ coverage
*.tsbuildinfo
.pnpm-store
__pycache__/
docs/.obsidian
docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example
infra/step-ca/dev-password
-10
View File
@@ -109,16 +109,6 @@ steps:
# `apk add` guarantees openssl is present on PR pipelines too (and is a
# fast no-op once the rebuilt image already ships it).
- apk add --no-cache openssl
# Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an
# installed `pi` binary at exactly this measured version — the test
# boots Pi's real tool registry to prove the read-only carve-out
# resolves to real, unshadowed builtins, and fails loud (by design)
# when the runtime is absent or drifts. The canonical Pi is
# @earendil-works/[email protected] exactly (@mariozechner/* is
# embedded-legacy). Step-level install because ci-base image publishes
# are currently blocked on registry auth; fold into Dockerfile.ci once
# that is fixed, keeping this as a fast no-op guard.
- npm install -g @earendil-works/[email protected]
# postgresql-client (pg_isready) is baked into ci-base.
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- |
@@ -417,7 +417,7 @@ describe('ConversationsController — search endpoint', () => {
},
];
brain = createMockBrain({ searchResults });
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
controller = new ConversationsController(brain as never);
});
it('returns matching messages for a valid search query', async () => {
@@ -479,7 +479,7 @@ describe('ConversationsController — search endpoint', () => {
describe('ConversationsController — message CRUD', () => {
it('listMessages returns 404 when conversation is not owned by user', async () => {
const brain = createMockBrain({ conversation: undefined });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
NotFoundException,
@@ -489,7 +489,7 @@ describe('ConversationsController — message CRUD', () => {
it('listMessages returns the messages for an owned conversation', async () => {
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
@@ -500,7 +500,7 @@ describe('ConversationsController — message CRUD', () => {
it('addMessage returns the persisted message', async () => {
const brain = createMockBrain({ conversation: makeConversation() });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
const result = await controller.addMessage(
CONV_ID,
@@ -35,25 +35,6 @@ function payload(content: string, messageId: string, correlationId: string): Dis
};
}
/**
* The chat runtime router must never be exercised on the Discord approval/stop control paths —
* those paths run entirely through the command-authorization, runtime-provider and durable-session
* dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService`
* slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because
* approval/stop never resolve a chat runtime, this fixture is never triggered and the integration
* stays a GREEN cross-surface control.
*/
function failIfUsedChatRuntimeRouter() {
return {
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the Discord control path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the Discord approval/stop path');
},
};
}
function authorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
return new CommandAuthorizationService(
@@ -132,7 +113,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
},
);
const gateway = new ChatGateway(
failIfUsedChatRuntimeRouter() as never,
{} as never,
{} as never,
{} as never,
{} as never,
@@ -60,7 +60,7 @@ describe('Resource ownership checks', () => {
// The repo enforces ownership via the WHERE clause; it returns undefined when the
// conversation does not belong to the requesting user.
brain.conversations.findById.mockResolvedValue(undefined);
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
NotFoundException,
@@ -1,8 +1,6 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
@@ -14,25 +12,10 @@ vi.mock('../routing/routing-engine.service.js', () => ({
}));
import { SessionsController } from '../sessions.controller.js';
import { AgentService } from '../agent.service.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AuthGuard } from '../../auth/auth.guard.js';
import { AUTH } from '../../auth/auth.tokens.js';
import { BRAIN } from '../../brain/brain.tokens.js';
import { CommandRegistryService } from '../../commands/command-registry.service.js';
import { CommandExecutorService } from '../../commands/command-executor.service.js';
import { RoutingEngineService } from '../routing/routing-engine.service.js';
import { ChatRuntimeRouter } from '../../chat/chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../../chat/embedded-chat.runtime.js';
import { ownConversation } from '../../chat/chat-runtime.js';
import type { LegacyRuntimeStream } from '../../chat/chat-runtime.js';
import { HarnessChatRuntime } from '../../chat/harness-chat.runtime.js';
import { HarnessRegistry } from '../../harness/harness.registry.js';
import { HARNESS_CONVERSATION_SERVICE_UNAVAILABLE } from '../../harness/harness.tokens.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
@@ -91,12 +74,6 @@ function makeAgentSession(owner = USER_A): AgentSession {
};
}
/**
* A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing
* USER_A's conversation id) is never granted the session. Because every method exists and no method
* throws for a wrong shape, production runs to its real ownership decision — the RED never comes from
* a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch.
*/
function makeScopedAgentService() {
const foreign = makeAgentSession(USER_A);
return {
@@ -110,7 +87,7 @@ function makeScopedAgentService() {
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')),
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
@@ -119,201 +96,6 @@ function makeScopedAgentService() {
};
}
type ScopedAgentService = ReturnType<typeof makeScopedAgentService>;
/**
* A structurally-complete harness conversation service that throws if any method is invoked.
* Fronted behind the legacy runtime's harness slot: the legacy path must never reach it.
*/
const failIfUsedConversationService = {
attach: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
detach: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
send: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
subscribeFrom: async function* () {
throw new Error('harness conversation service must not be reached on the legacy path');
},
} as unknown as HarnessConversationService;
/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */
const boundConversationService = {
attach: () => Promise.reject(new Error('unused')),
detach: () => Promise.reject(new Error('unused')),
send: () => Promise.reject(new Error('unused')),
subscribeFrom: async function* () {
throw new Error('unused');
},
} as unknown as HarnessConversationService;
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
const registry = new HarnessRegistry();
for (const id of adapterIds) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return registry;
}
/**
* Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime}
* that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to
* reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService.
* The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a
* call landing on it proves the router-delegation redesign is live rather than the old direct path.
*/
function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter {
const embedded = new EmbeddedChatRuntime(agentService as never);
const harness = new HarnessChatRuntime(failIfUsedConversationService);
const router = new ChatRuntimeRouter(
new HarnessRegistry(),
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
embedded,
harness,
'legacy',
);
router.onModuleInit();
return router;
}
/**
* The AgentService method names the controller/gateway must NEVER drive on the runtime at the
* delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record
* one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime
* call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text.
*/
const FORBIDDEN_AGENT_OPS = [
'getSession',
'createSession',
'onEvent',
'addChannel',
'prompt',
'setThinking',
'abort',
] as const;
/**
* Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields
* an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at
* INVOCATION time and forwards to the real method (bound to the real target, so the router's internal
* delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING
* properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a
* canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim.
*
* The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router
* at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the
* production source cannot. This replaces the earlier `source.toContain('<frozen op>')` proof — which
* a dead declaration could satisfy while production still executed a shim — with invocation evidence.
*/
function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter {
return new Proxy(target, {
get(t, prop) {
const value = Reflect.get(t, prop);
if (typeof value === 'function' && typeof prop === 'string') {
return (...args: unknown[]) => {
calls.push(prop);
return (value as (...a: unknown[]) => unknown).apply(t, args);
};
}
return value;
},
}) as ChatRuntimeRouter;
}
/**
* Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts).
*
* BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider
* (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production
* resolves whichever its constructor declares:
* - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the
* router (and its embedded fake) is never reached.
* - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never
* touched (stays at zero) and scope is observed inside the embedded fake behind the router.
* The SAME test body reds today and greens later; a method-for-method AgentService shim on the router
* records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and
* restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed
* scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link
* makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings.
*/
function buildRestModule(
directAgentService: ScopedAgentService,
embeddedAgentService: ScopedAgentService,
routerCalls: string[],
): Promise<TestingModule> {
return (
Test.createTestingModule({
controllers: [ChatController],
providers: [
{ provide: AgentService, useValue: directAgentService },
{
provide: ChatRuntimeRouter,
useFactory: () =>
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
},
],
})
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects
// AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph
// resolves and the test reds on BEHAVIOUR, not on a DI collection error.
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile()
);
}
function buildGatewayModule(
directAgentService: ScopedAgentService,
embeddedAgentService: ScopedAgentService,
routerCalls: string[],
): Promise<TestingModule> {
const brain = {
conversations: {
// The sender OWNS this durable conversation, so the browser-send admission gate lets the turn
// reach the router seam. Foreignness is asserted downstream at the in-memory agent session
// (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection
// property has its own dedicated coverage.
findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }),
},
};
return Test.createTestingModule({
providers: [
ChatGateway,
{ provide: AgentService, useValue: directAgentService },
{ provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } },
{ provide: BRAIN, useValue: brain },
{ provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } },
{ provide: CommandExecutorService, useValue: { execute: vi.fn() } },
{
provide: RoutingEngineService,
useValue: {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
},
},
{
provide: ChatRuntimeRouter,
useFactory: () =>
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
},
],
}).compile();
}
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
it('requires explicit owner+tenant scope on protected session operations', () => {
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
@@ -370,66 +152,50 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => {
// TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot
// swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived
// scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path.
it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency
const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation
const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam
const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls);
try {
const controller = moduleRef.get(ChatController, { strict: false });
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
const agentService = makeScopedAgentService();
const controller = new ChatController(agentService as never);
// Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN.
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toBeDefined();
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toMatchObject({ status: 404 });
// Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail.
// RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router.
// Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the
// controller @Inject(AgentService) and never calls the router). GREEN once it drives the op.
expect
.soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router')
.toContain('completeLegacyRestTurn');
// RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on
// the router. An AgentService-shaped router shim records one of these → RED, defeating the shim
// on invocation evidence (not source text). A dead named method added alongside the shim does not
// help: it is never invoked, so it never enters routerCalls while a forbidden op still does.
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today;
// restoring the direct injection keeps it failing).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the
// router (fails today; the router path is never taken).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Zero foreign mutation on either path (holds today and at GREEN).
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
// Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the
// controller no longer declares the direct embedded AgentService dependency. A negative source
// check cannot be satisfied by dead text — it only fails when the injection is present.
const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect.soft(controllerSource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.prompt).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => {
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
function makeGateway(agentService = makeScopedAgentService()) {
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
const commandExecutor = { execute: vi.fn() };
const routingEngine = {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
commandRegistry as never,
commandExecutor as never,
routingEngine as never,
);
return { gateway, agentService };
}
function makeSocket() {
return {
id: 'socket-b',
@@ -440,519 +206,57 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router
};
}
// TESS test B — WebSocket send/attach.
it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
}),
).catch(() => undefined);
// RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router.
expect
.soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router')
.toContain('prepareLegacySocketTurn');
// RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN).
expect.soft(directAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(directAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
// Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency.
const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
expect.soft(gatewaySource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
});
// TESS test C — WebSocket set:thinking.
it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleSetThinking(socket as never, {
conversationId: CONVERSATION_ID,
level: 'high',
}),
).catch(() => undefined);
// RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router.
expect
.soft(routerCalls, 'gateway must invoke setLegacyThinking on the router')
.toContain('setLegacyThinking');
// RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test D — WebSocket abort.
it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }),
).catch(() => undefined);
// RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router.
expect
.soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router')
.toContain('abortLegacyTurn');
// RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal.
it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => {
// pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and
// must be refused with a fixed typed code, touching neither the embedded AgentService nor the
// harness conversation service.
const agentService = makeScopedAgentService();
const embedded = new EmbeddedChatRuntime(agentService as never);
const harnessConversation = {
attach: vi.fn(),
detach: vi.fn(),
send: vi.fn(),
subscribeFrom: vi.fn(),
};
const harness = new HarnessChatRuntime(harnessConversation as never);
const router = new ChatRuntimeRouter(
registryWith(['pi']),
boundConversationService,
embedded,
harness,
'pi-rpc',
);
router.onModuleInit();
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const gateway = new ChatGateway(
router as never,
{} as never,
brain as never,
{ getManifest: vi.fn().mockReturnValue([]) } as never,
{ execute: vi.fn() } as never,
{ resolve: vi.fn() } as never,
);
const socket = {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'route me',
}),
).catch(() => undefined);
await gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
});
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.onEvent).not.toHaveBeenCalled();
expect(agentService.addChannel).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ code: 'runtime_unsupported' }),
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
expect(agentService.getSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(harnessConversation.attach).not.toHaveBeenCalled();
expect(harnessConversation.send).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5).
// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService
// fake (every touched method exists, so a RED can only come from behavior, never a
// `getSession is not a function` TypeError). Ownership context is minted through the
// real `ownConversation` factory — the only sanctioned way to reach a port op.
// ---------------------------------------------------------------------------
const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId };
const CONVERSATION_UNAVAILABLE_RESULT = {
ok: false,
code: 'conversation_unavailable',
retryable: false,
} as const;
/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */
function makeStream(): LegacyRuntimeStream {
return { channelId: 'websocket:test-1', onEvent: vi.fn() };
}
/**
* getSession → undefined (session missing), createSession → rejects with `err`. Exercises the
* `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves
* the turn short-circuited before any dispatch.
*/
function makeCollapsingAgentService(err: Error) {
return {
getSession: vi.fn(() => undefined),
createSession: vi.fn().mockRejectedValue(err),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
}
/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */
function makeLeaseAgentService() {
const session = makeAgentSession(USER_A);
const unsubscribe = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => unsubscribe),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
return { svc, unsubscribe, session };
}
/**
* getSession → a live owned session (REST resolveOrCreate succeeds), onEvent returns a `detach`
* spy, and `prompt` REJECTS with a non-timeout error. Drives the REST-turn catch path so the single
* idempotent teardown must clear the 120s timeout and detach the listener exactly once.
*/
function makeRejectingPromptAgentService() {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockRejectedValue(new Error('agent backend exploded')),
recordTokenUsage: vi.fn(),
};
return { svc, detach };
}
describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner'));
const runtime = new EmbeddedChatRuntime(svc as never);
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new NotFoundException('no such conversation'));
const runtime = new EmbeddedChatRuntime(svc as never);
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' });
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => {
const foreign = new EmbeddedChatRuntime(
makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never,
);
const missing = new EmbeddedChatRuntime(
makeCollapsingAgentService(new NotFoundException('no such conversation')) as never,
);
const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' });
const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' });
expect(foreignResult).toEqual(missingResult);
expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
});
});
describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => {
const { svc } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
const first = await lease.dispatch();
expect(first).toEqual({ ok: true, value: undefined });
expect(svc.prompt).toHaveBeenCalledTimes(1);
const second = await lease.dispatch();
expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false });
// Zero additional effect — the second dispatch must not prompt again.
expect(svc.prompt).toHaveBeenCalledTimes(1);
});
it('disposes once; a second dispose is a silent no-op that never re-detaches or destroys the session', async () => {
const { svc, unsubscribe, session } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
await lease.dispose();
await lease.dispose();
// Listener + channel torn down exactly once across two dispose calls.
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(svc.removeChannel).toHaveBeenCalledTimes(1);
// Disposal never terminates the underlying session or process.
expect(session.piSession.abort).not.toHaveBeenCalled();
expect(session.piSession.dispose).not.toHaveBeenCalled();
});
it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => {
const { svc, unsubscribe } = makeLeaseAgentService();
svc.addChannel = vi.fn(() => {
throw new Error('channel attach failed');
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
const runtime = new EmbeddedChatRuntime(svc as never);
// Must NOT throw out of the port — a partial setup collapses to a total safe failure.
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(false);
// Exactly what was acquired (the event listener) is rolled back.
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => {
const { svc, detach } = makeRejectingPromptAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
// A rejected `done` promise firing after completeLegacyRestTurn has already returned would
// surface as an unhandledRejection — the leak this test fences. Capture any that escape.
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const result = await runtime.completeLegacyRestTurn(ctx, {
content: 'trigger a backend failure',
});
// The rejection collapses to a total safe failure (not a timeout) — never throws out of the port.
expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false });
// The single idempotent dispose ran in the catch: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second
// detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
// Let any scheduled rejection surface on a real macrotask, then confirm none did.
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
// The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential
// `await prompt()` the timer could never even be observed, so the turn hung forever.
prompt: vi.fn(() => new Promise<void>(() => undefined)),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'a prompt that never returns',
});
// No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all
// installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
// The single idempotent dispose ran on the timeout path: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// Advancing far past the deadline fires nothing more: dispose cleared the timer.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
let rejectPrompt: (reason: unknown) => void = () => undefined;
const prompting = new Promise<void>((_resolve, reject) => {
rejectPrompt = reject;
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn(() => prompting),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'prompt settles after the deadline',
});
// The timeout wins the race while prompt is still pending.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
expect(detach).toHaveBeenCalledTimes(1);
// The prompt now rejects LATE — after the turn already returned its timeout result. Because
// Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late
// rejection is already observed and must not escape as an unhandledRejection.
rejectPrompt(new Error('late backend failure'));
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,920 +0,0 @@
import 'reflect-metadata';
import { Global, Module } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CommandsModule } from '../commands/commands.module.js';
import { HarnessModule } from '../harness/harness.module.js';
import { ChatModule } from './chat.module.js';
import { ChatGateway } from './chat.gateway.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
HARNESS_REGISTRY,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import {
ChatRuntimeUnavailableError,
ownConversation,
type ChatRuntime,
type ChatRuntimeMode,
type LegacyEmbeddedChatPort,
type LegacyRuntimeStream,
type LegacySessionPresentation,
type LegacySocketTurnLease,
type OwnedConversationContext,
} from './chat-runtime.js';
import { AppModule } from '../app.module.js';
import { ProviderService } from '../agent/provider.service.js';
/**
* Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one
* runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and
* never downgrades `pi-rpc` to embedded execution. Red-first: the router is an
* unimplemented stub, so every behavioural assertion below fails until Step Three.
*/
const embedded: ChatRuntime = { kind: 'embedded' };
const harness: ChatRuntime = { kind: 'harness' };
/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */
const boundConversationService = {
attach: () => Promise.reject(new Error('unused')),
detach: () => Promise.reject(new Error('unused')),
send: () => Promise.reject(new Error('unused')),
subscribeFrom: async function* () {
throw new Error('unused');
},
} as unknown as HarnessConversationService;
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
const registry = new HarnessRegistry();
for (const id of adapterIds) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return registry;
}
function buildRouter(
mode: ChatRuntimeMode,
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
): ChatRuntimeRouter {
return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode);
}
/**
* Tear down a module that was deliberately driven to a fail-closed init.
* `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing
* (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed
* startup error, this time into teardown. Each caller here has already captured and asserted that
* exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise —
* swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly.
*/
async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise<void> {
await moduleRef.close().catch((err: unknown) => {
if (err instanceof ChatRuntimeUnavailableError) return;
throw err;
});
}
describe('ChatRuntimeRouter', () => {
it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => {
const router = buildRouter('pi-rpc', {
adapters: ['pi'],
service: boundConversationService,
});
expect(() => router.onModuleInit()).not.toThrow();
expect(router.active).toBe(harness);
expect(router.active.kind).toBe('harness');
});
it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => {
// Empty registry + unavailable service: legacy must ignore both and still start.
const router = buildRouter('legacy', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
expect(() => router.onModuleInit()).not.toThrow();
expect(router.active).toBe(embedded);
expect(router.active.kind).toBe('embedded');
});
it('fails closed at init when pi-rpc mode has no registered pi adapter', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: boundConversationService,
});
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw when the pi adapter is absent');
} catch (err) {
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
}
});
it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => {
const router = buildRouter('pi-rpc', {
adapters: ['pi'],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw when the conversation service is unbound');
} catch (err) {
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
}
});
it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
// A failed pi-rpc init must not silently expose the embedded runtime.
expect(() => router.active).toThrow();
let leaked: ChatRuntime | undefined;
try {
leaked = router.active;
} catch {
leaked = undefined;
}
expect(leaked).not.toBe(embedded);
});
it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw');
} catch (err) {
const message = (err as ChatRuntimeUnavailableError).message;
expect(message).toBe(
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
);
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/);
}
});
});
/**
* Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input).
*
* The unit suite above constructs the router but never invokes a legacy port operation, so the
* six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that
* deletes one of them SURVIVES for lack of a test that drives that operation. This group closes
* that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a
* valid branded {@link OwnedConversationContext} and valid input, against a recording embedded
* stub whose method returns a distinguishable `ok:true` success and increments a per-op counter.
*
* For each operation:
* - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }`
* result AND that the embedded stub was touched zero times (no effects);
* - the paired legacy test proves that same stub method IS reached and returns its distinguishable
* success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful,
* not vacuously true because the stub could never be called.
*
* Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED
* (the router returns the embedded `ok:true` value and records the call), with every outer guard
* and the other five inner guards intact. `next` is untouched; nothing here changes production.
*/
describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => {
const RUNTIME_UNSUPPORTED = {
ok: false,
code: 'runtime_unsupported',
retryable: false,
} as const;
const PRESENTATION: LegacySessionPresentation = {
provider: 'embedded-provider',
modelId: 'embedded-model',
thinkingLevel: 'low',
availableThinkingLevels: ['low', 'high'],
};
const stream: LegacyRuntimeStream = {
channelId: 'websocket:test-socket',
onEvent: () => {},
};
const ctx = (): OwnedConversationContext =>
ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' });
/**
* Per-operation invocation counters with declared keys (not an index signature) so each
* `calls.<op>` is definitely `number` under `noUncheckedIndexedAccess`.
*/
type LegacyPortCallCounts = {
completeLegacyRestTurn: number;
prepareLegacySocketTurn: number;
setLegacyThinking: number;
abortLegacyTurn: number;
applyLegacyModelOverride: number;
readLegacySessionPresentation: number;
dispatchVerifiedDiscordIngress: number;
};
/**
* An embedded port that records every invocation and returns a distinguishable `ok:true`
* value per operation. If a router op reaches it (its guard removed), both the recorded call
* count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result.
*/
function recordingEmbeddedPort(): {
port: ChatRuntime & LegacyEmbeddedChatPort;
calls: LegacyPortCallCounts;
} {
const calls: LegacyPortCallCounts = {
completeLegacyRestTurn: 0,
prepareLegacySocketTurn: 0,
setLegacyThinking: 0,
abortLegacyTurn: 0,
applyLegacyModelOverride: 0,
readLegacySessionPresentation: 0,
dispatchVerifiedDiscordIngress: 0,
};
const lease: LegacySocketTurnLease = {
presentation: PRESENTATION,
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
dispose: () => Promise.resolve(),
};
const port: ChatRuntime & LegacyEmbeddedChatPort = {
kind: 'embedded',
completeLegacyRestTurn: () => {
calls.completeLegacyRestTurn += 1;
return Promise.resolve({
ok: true,
value: { text: 'EMBEDDED-REST', presentation: PRESENTATION },
});
},
prepareLegacySocketTurn: () => {
calls.prepareLegacySocketTurn += 1;
return Promise.resolve({ ok: true, value: lease });
},
setLegacyThinking: () => {
calls.setLegacyThinking += 1;
return { ok: true, value: PRESENTATION };
},
abortLegacyTurn: () => {
calls.abortLegacyTurn += 1;
return Promise.resolve({ ok: true, value: undefined });
},
applyLegacyModelOverride: () => {
calls.applyLegacyModelOverride += 1;
return { ok: true, value: PRESENTATION };
},
readLegacySessionPresentation: () => {
calls.readLegacySessionPresentation += 1;
return { ok: true, value: PRESENTATION };
},
dispatchVerifiedDiscordIngress: () => {
calls.dispatchVerifiedDiscordIngress += 1;
return Promise.resolve({
ok: true,
value: {
presentation: PRESENTATION,
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
dispose: () => Promise.resolve(),
},
});
},
};
return { port, calls };
}
function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
return new ChatRuntimeRouter(
registryWith(['pi']),
boundConversationService,
port,
harness,
'pi-rpc',
);
}
function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
return new ChatRuntimeRouter(
registryWith([]),
boundConversationService,
port,
harness,
'legacy',
);
}
// completeLegacyRestTurn ---------------------------------------------------
it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.completeLegacyRestTurn).toBe(0);
});
it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
expect(result.ok).toBe(true);
expect(calls.completeLegacyRestTurn).toBe(1);
});
// prepareLegacySocketTurn --------------------------------------------------
it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).prepareLegacySocketTurn(
ctx(),
{ content: 'hello' },
stream,
);
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.prepareLegacySocketTurn).toBe(0);
});
it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).prepareLegacySocketTurn(
ctx(),
{ content: 'hello' },
stream,
);
expect(result.ok).toBe(true);
expect(calls.prepareLegacySocketTurn).toBe(1);
});
// setLegacyThinking (sync) -------------------------------------------------
it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).setLegacyThinking(ctx(), 'high');
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.setLegacyThinking).toBe(0);
});
it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).setLegacyThinking(ctx(), 'high');
expect(result.ok).toBe(true);
expect(calls.setLegacyThinking).toBe(1);
});
// abortLegacyTurn ----------------------------------------------------------
it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).abortLegacyTurn(ctx());
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.abortLegacyTurn).toBe(0);
});
it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).abortLegacyTurn(ctx());
expect(result.ok).toBe(true);
expect(calls.abortLegacyTurn).toBe(1);
});
// applyLegacyModelOverride (sync) ------------------------------------------
it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.applyLegacyModelOverride).toBe(0);
});
it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
expect(result.ok).toBe(true);
expect(calls.applyLegacyModelOverride).toBe(1);
});
// readLegacySessionPresentation (sync) -------------------------------------
it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).readLegacySessionPresentation(ctx());
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.readLegacySessionPresentation).toBe(0);
});
it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).readLegacySessionPresentation(ctx());
expect(result.ok).toBe(true);
expect(calls.readLegacySessionPresentation).toBe(1);
});
// dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) ---------
it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => {
const { port, calls } = recordingEmbeddedPort();
const discordCtx = ctx() as unknown as Parameters<
ChatRuntimeRouter['dispatchVerifiedDiscordIngress']
>[0];
const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream);
expect(result.ok).toBe(true);
expect(calls.dispatchVerifiedDiscordIngress).toBe(1);
});
});
/**
* Task Five, Step Two — group 1 (real Nest module-graph readiness).
*
* The unit suite above constructs the router directly. This group drives the SAME contract
* through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting
* idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry`
* via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest
* lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose
* `onModuleInit` throws a generic Error, so:
* - readiness cases fail because the graph never comes up (init rejects), and
* - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed
* `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that
* "throws anything" cannot mask these greens.
* The router is NOT wired into a production module yet, so it is provided here via a factory
* over the real registry token. Importing the real `ChatModule` bare is deliberately avoided:
* it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a
* collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router.
*/
describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => {
async function bootRouterGraph(
mode: ChatRuntimeMode,
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
) {
const moduleRef = await Test.createTestingModule({
imports: [HarnessModule],
providers: [
{
provide: ChatRuntimeRouter,
useFactory: (registry: HarnessRegistry) =>
new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode),
inject: [HARNESS_REGISTRY],
},
],
})
// The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern,
// never exercised here); stub it so the graph resolves. The registry is NOT overridden —
// group 1 asserts against the genuine production HarnessRegistry.
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
// Resolve the production registry singleton and register the requested adapters ON IT, so
// the router (which injects the same singleton) sees them when its lifecycle hook runs.
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
for (const id of opts.adapters) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return moduleRef;
}
// Capture an init rejection without letting a resolved init masquerade as success.
const initError = (moduleRef: { init(): Promise<unknown> }): Promise<unknown> =>
moduleRef.init().then(
() => new Error('module init resolved but the contract requires it to reject'),
(err: unknown) => err,
);
it('brings the graph up and resolves only the harness runtime in pi-rpc mode (pi adapter + bound service)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: ['pi'],
service: boundConversationService,
});
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active).toBe(harness);
expect(router.active.kind).toBe('harness');
} finally {
await moduleRef.close();
}
});
it('brings the graph up in legacy mode over the REAL empty HarnessRegistry and resolves only the embedded runtime', async () => {
const moduleRef = await bootRouterGraph('legacy', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
// Defense-in-depth: the production module wires the genuine registry, empty by default —
// guards against a test-double registry silently satisfying the readiness check.
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
expect(registry).toBeInstanceOf(HarnessRegistry);
expect(registry.list()).toHaveLength(0);
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active).toBe(embedded);
expect(router.active.kind).toBe('embedded');
} finally {
await moduleRef.close();
}
});
it('fails closed at module init when pi-rpc mode has no registered pi adapter (specific typed error, not a stub throw)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('fails closed at module init when pi-rpc mode has the unavailable conversation-service sentinel', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: ['pi'],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('surfaces only fixed, browser-safe failure text when the graph fails closed (no stub/exception detail)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
const message = (err as ChatRuntimeUnavailableError).message;
expect(message).toBe(
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
);
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(|not implemented/);
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
});
/**
* Task Five, Step Two — group 1b (production ChatModule wiring, declaration proof).
*
* Correction #1 (Scrappy fe3e02) asked for a red that imports the real `ChatModule` and calls
* `module.init()`. Investigated and found impractical/masking-prone: `ChatModule` provides
* `ChatGateway`, whose 10-argument constructor injects app-global providers (AgentService, AUTH,
* BRAIN, RoutingEngineService) plus the Commands/GC/Mcp/Reload subsystems across a forwardRef
* cycle. Booting it in isolation is a full-app integration boot — "override only unrelated
* dependencies" balloons into faking ~4 subsystems, and `overrideProvider` cannot even grant the
* cross-module export-scope visibility ChatGateway needs (probe: `ChatGateway` unresolved at
* `CommandExecutorService`). That is exactly the STOP-and-return branch of the directive.
*
* The faithful, unmaskable cover instead of a fragile boot: read the PRODUCTION `ChatModule`'s own
* Nest `@Module` metadata to prove it DECLARES the exclusive router provider and imports the real
* `HarnessModule` (the genuine registry source). This inspects the actual module object — not
* source text, not a test factory — so nothing can mask it. Group 1 above separately proves the
* router RESOLVES against the real, empty `HarnessRegistry` through the Nest lifecycle; the union
* of the two covers "the router is wired through ChatModule to the real registry" without the
* impractical single-graph boot. RED today (ChatModule provides only ChatGateway and imports only
* CommandsModule); GREEN once Step Three registers the router and imports HarnessModule.
*/
describe('ChatModule production wiring (Task Five, Step Two group 1b — declaration proof)', () => {
// Unwrap a forwardRef(() => Module) import to the module it references; pass others through.
const resolveImport = (imp: unknown): unknown =>
imp &&
typeof imp === 'object' &&
typeof (imp as { forwardRef?: unknown }).forwardRef === 'function'
? (imp as { forwardRef: () => unknown }).forwardRef()
: imp;
// A provider entry is either a class (shorthand) or a { provide, ... } object; take its token.
const providerToken = (provider: unknown): unknown =>
typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
it('declares the exclusive ChatRuntimeRouter as a provider on the production ChatModule', () => {
const providers: unknown[] = Reflect.getMetadata('providers', ChatModule) ?? [];
expect(providers.map(providerToken)).toContain(ChatRuntimeRouter);
});
it('imports the real HarnessModule into the production ChatModule (registry source, not a test double)', () => {
const imports: unknown[] = Reflect.getMetadata('imports', ChatModule) ?? [];
expect(imports.map(resolveImport)).toContain(HarnessModule);
});
});
/**
* Task Five, Step Two — group 1c (bounded real-`ChatModule` boot).
*
* Scrappy adjudication d67d2b (option c): boot the ACTUAL production `ChatModule` as the SUT and
* assert the exclusive router resolves THROUGH it — the single-graph proof group 1 (router over the
* real registry) and group 1b (production-module metadata) each cover only a half of. The heavy,
* UNRELATED cycle is the only thing bounded away, per the established isolation pattern in
* `apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`:
* - `CommandsModule` (drags the Commands <-> Reload <-> Chat forwardRef cycle plus GC/Mcp/queue)
* is replaced wholesale with an empty module via `.overrideModule(...).useModule(...)`;
* - `ChatGateway` (10-arg constructor, an HTTP/socket concern never exercised here) is replaced
* with an inert value;
* - the sole legacy-controller dependency, `AgentService`, is supplied by a tiny `@Global()` stub;
* - the HTTP-only `AuthGuard` is stubbed.
* Nothing about the router, `HarnessModule`, the registry, or the conversation-service binding is
* faked in the production-legacy case — those are retrieved from the REAL `ChatModule` graph. Mode
* is driven only through the production `CHAT_HARNESS_RUNTIME` env contract (`resolveChatRuntimeMode`).
*
* Red-first: today `ChatModule` neither imports `HarnessModule` nor provides `ChatRuntimeRouter`, so
* the booted graph contains no router/registry/conversation-service tokens. `init()` may resolve
* (there is no router lifecycle hook yet to reject), so every case fails on the MISSING actual
* router/registry/service wiring — not on unrelated DI, which is bounded away. GREEN at Step Three
* once `ChatModule` imports `HarnessModule`, provides the exclusive router, and binds the
* conversation-service token (defaulting to the unavailable sentinel).
*/
describe('ChatModule bounded real boot (Task Five, Step Two group 1c)', () => {
// The unrelated heavy cycle, replaced wholesale — not stubbed provider-by-provider.
@Module({})
class EmptyCommandsModule {}
// The ONLY genuine legacy dependency of the real ChatController, supplied inertly and globally so
// the pre-refactor controller instantiates without dragging AgentModule into the graph.
@Global()
@Module({
providers: [{ provide: AgentService, useValue: {} }],
exports: [AgentService],
})
class LegacyControllerDepsModule {}
const ORIGINAL_RUNTIME_ENV = process.env['CHAT_HARNESS_RUNTIME'];
afterEach(() => {
if (ORIGINAL_RUNTIME_ENV === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
else process.env['CHAT_HARNESS_RUNTIME'] = ORIGINAL_RUNTIME_ENV;
});
/**
* Boot the real ChatModule with only the unrelated cycle bounded away. `mode` is set through the
* genuine production env contract before providers instantiate. The optional overrides replace
* the registry / conversation-service the router injects, exercising the pi-rpc precondition
* branches through the ACTUAL module (they are no-ops today because those tokens are not yet in
* the graph — which is exactly why the router-retrieval assertions go red).
*/
async function bootChatModule(
mode: ChatRuntimeMode,
overrides: {
registryAdapters?: readonly string[];
conversationService?: HarnessConversationServiceBinding;
} = {},
): Promise<TestingModule> {
if (mode === 'pi-rpc') process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
else delete process.env['CHAT_HARNESS_RUNTIME'];
let builder = Test.createTestingModule({
imports: [LegacyControllerDepsModule, ChatModule],
})
.overrideModule(CommandsModule)
.useModule(EmptyCommandsModule)
.overrideProvider(ChatGateway)
.useValue({})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true });
if (overrides.registryAdapters) {
builder = builder
.overrideProvider(HARNESS_REGISTRY)
.useValue(registryWith(overrides.registryAdapters));
}
if (overrides.conversationService !== undefined) {
builder = builder
.overrideProvider(HARNESS_CONVERSATION_SERVICE)
.useValue(overrides.conversationService);
}
return builder.compile();
}
// Capture an init rejection without letting a resolved init masquerade as success.
const initError = (moduleRef: TestingModule): Promise<unknown> =>
moduleRef.init().then(
() => new Error('module init resolved but the contract requires it to reject'),
(err: unknown) => err,
);
it('legacy mode: the actual router resolves the embedded runtime, the actual registry is empty, and the conversation-service token is the unavailable sentinel', async () => {
const moduleRef = await bootChatModule('legacy');
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('embedded');
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
expect(registry).toBeInstanceOf(HarnessRegistry);
expect(registry.list()).toHaveLength(0);
const service = moduleRef.get<HarnessConversationServiceBinding>(
HARNESS_CONVERSATION_SERVICE,
{
strict: false,
},
);
expect(service).toBe(HARNESS_CONVERSATION_SERVICE_UNAVAILABLE);
} finally {
await moduleRef.close();
}
});
it('pi-rpc mode over the REAL empty registry fails closed at init with the typed adapter-unavailable error', async () => {
const moduleRef = await bootChatModule('pi-rpc');
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('pi-rpc mode with a pi adapter present but the sentinel conversation service fails closed with the typed conversation-service-unavailable error', async () => {
const moduleRef = await bootChatModule('pi-rpc', {
registryAdapters: ['pi'],
conversationService: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('pi-rpc mode with a pi adapter and a bound conversation service: the actual router selects the harness runtime', async () => {
const moduleRef = await bootChatModule('pi-rpc', {
registryAdapters: ['pi'],
conversationService: boundConversationService,
});
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('harness');
} finally {
await moduleRef.close();
}
});
});
/**
* Task Five, Step Two — group 2 (WHOLE production `AppModule` boot, legacy end-to-end wiring).
*
* The groups above bound away the heavy cycle to isolate the router. This group instead boots the
* ACTUAL production `AppModule` (the exact graph `main.ts` runs) in the default LEGACY chat-runtime
* mode, overriding ONLY the storage/network side-effect adapters so the boot is bounded and offline
* — never the chat/router/harness/reload/commands surface under test. The bounded fakes are exactly
* the disk/network leaves:
* - `ProviderService` (the #1 hang risk: its real `onModuleInit` starts an unref'd health-check
* `setInterval` and fetches Ollama over HTTP) → inert no-op instance;
* - `DB_HANDLE`/`DB` → a fake Drizzle-shaped handle that satisfies `runPgliteMigrations` (the local
* tier's `DatabaseModule.onModuleInit`) AND `DefaultRoutingRulesSeed.onModuleInit` (which reads a
* system-rule count — the fake reports rules already present so the seed insert is skipped),
* opening no real database;
* - `STORAGE_ADAPTER`/`MEMORY`/`MEMORY_ADAPTER`/`AUTH`/`BRAIN`/`LOG_SERVICE` → inert fakes so no
* storage/auth/log backend is contacted.
* Local tier (the repo's `mosaic.config.json`) already disables BullMQ/Redis and the queue handles;
* Discord/Telegram/MCP plugins are env-gated and disarmed by deleting their tokens. Nothing about the
* router, `ChatModule`, `HarnessModule`, or `ChatGateway` is faked — those come from the REAL graph.
*
* The boot+init MUST SUCCEED cleanly (proven by `beforeAll` completing and the ChatGateway test
* passing). Red-first: on this branch `ChatRuntimeRouter` is registered in NO module (ChatModule
* provides only ChatGateway), so `moduleRef.get(ChatRuntimeRouter)` throws `UnknownElementException`
* — a WIRING gap, NOT an init failure. That single retrieval is the intended behavioural red; it
* flips green once Step Three registers the exclusive router. The ChatGateway retrieval and its
* browser-facing method surface are asserted alongside and pass today, pinning that the boot itself
* is healthy so the router failure cannot be mistaken for a mis-shaped fake or an unbounded side
* effect.
*/
describe('AppModule production boot — legacy ChatRuntimeRouter wiring (Task Five, Step Two group 2)', () => {
// A Drizzle-shaped fake that satisfies both DB consumers reached during a local-tier init:
// • runPgliteMigrations(): reads handle.db.$client.exec + handle.db.execute(SELECT hashes);
// exec is a no-op and execute yields an empty ledger, so migration statements no-op through.
// • DefaultRoutingRulesSeed.seedDefaultRules(): db.select().from().where() must resolve to a
// row set — we report a non-zero system-rule count so the seeding INSERT branch is skipped.
const fakeDb = {
$client: { exec: async (): Promise<void> => {} },
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
select: () => ({
from: () => ({
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
}),
}),
insert: () => ({ values: async (): Promise<void> => {} }),
};
const fakeDbHandle = { db: fakeDb, close: async (): Promise<void> => {} };
const fakeStorageAdapter = {
name: 'fake',
migrate: async (): Promise<void> => {},
close: async (): Promise<void> => {},
};
// Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch.
const fakeProviderService = {
onModuleInit: async (): Promise<void> => {},
onModuleDestroy: (): void => {},
getRegistry: () => ({
getAvailable: () => [],
getAll: () => [],
find: () => undefined,
}),
getDefaultModel: () => undefined,
listAvailableModels: () => [],
listProviders: () => [],
getAdapter: () => undefined,
getProvidersHealth: () => [],
};
const fakeBrain = { conversations: {}, agents: {} };
const BOOT_TIMEOUT_MS = 120_000;
let moduleRef: TestingModule;
let envSnapshot: Record<string, string | undefined>;
beforeAll(async () => {
envSnapshot = { ...process.env };
// Env hygiene: disarm the network-facing plugins/adapters and pin the legacy runtime mode.
delete process.env['DATABASE_URL'];
delete process.env['DISCORD_BOT_TOKEN'];
delete process.env['TELEGRAM_BOT_TOKEN'];
delete process.env['MCP_SERVERS'];
delete process.env['CHAT_HARNESS_RUNTIME']; // resolveChatRuntimeMode → 'legacy'
process.env['MOSAIC_STORAGE_TIER'] = 'local';
moduleRef = await Test.createTestingModule({ imports: [AppModule] })
// Storage/network side-effect adapters ONLY — never the router/chat/harness surface under test.
.overrideProvider('DB_HANDLE')
.useValue(fakeDbHandle)
.overrideProvider('DB')
.useValue(fakeDb)
.overrideProvider('STORAGE_ADAPTER')
.useValue(fakeStorageAdapter)
.overrideProvider('AUTH')
.useValue({})
.overrideProvider('BRAIN')
.useValue(fakeBrain)
.overrideProvider('LOG_SERVICE')
.useValue({})
.overrideProvider('MEMORY')
.useValue({})
.overrideProvider('MEMORY_ADAPTER')
.useValue({})
.overrideProvider(ProviderService)
.useValue(fakeProviderService)
.compile();
// The boot itself MUST succeed cleanly — a rejection here is a bounding failure, not the red.
await moduleRef.init();
}, BOOT_TIMEOUT_MS);
afterAll(async () => {
if (moduleRef) await moduleRef.close();
for (const key of Object.keys(process.env)) {
if (!(key in envSnapshot)) delete process.env[key];
}
for (const [key, value] of Object.entries(envSnapshot)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
// Passes TODAY: the real ChatGateway is provided by the real ChatModule and its browser-facing
// surface exists. This pins that the whole-AppModule boot came up healthy, so the router failure
// below is unambiguously a wiring gap and not a mis-shaped fake or an unbounded side effect.
it('boots the whole AppModule and exposes the real ChatGateway with its browser-facing methods', () => {
const gateway = moduleRef.get(ChatGateway, { strict: false });
expect(typeof gateway.broadcastReload).toBe('function');
expect(typeof gateway.getModelOverride).toBe('function');
expect(typeof gateway.setModelOverride).toBe('function');
expect(typeof gateway.broadcastSessionInfo).toBe('function');
});
// RED TODAY: ChatRuntimeRouter is registered in no module on this branch, so this retrieval throws
// UnknownElementException — the intended red-first wiring failure. GREEN once Step Three registers
// the exclusive router in the production graph, where legacy mode resolves the embedded runtime.
it('resolves the exclusive ChatRuntimeRouter to the embedded runtime in legacy mode', () => {
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('embedded');
});
});
@@ -1,173 +0,0 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
isHarnessConversationServiceAvailable,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import type {
ChatRuntime,
ChatRuntimeMode,
LegacyBrowserMessagePayload,
LegacyEmbeddedChatPort,
LegacyRuntimeResult,
LegacyRuntimeStream,
LegacySessionPresentation,
LegacySocketTurnLease,
OwnedConversationContext,
VerifiedDiscordIngressContext,
VerifiedDiscordTurnLease,
} from './chat-runtime.js';
import { ChatRuntimeUnavailableError, resolveChatRuntimeMode } from './chat-runtime.js';
/** The fixed fail-closed result for a legacy browser operation issued under `pi-rpc`. */
const RUNTIME_UNSUPPORTED = {
ok: false as const,
code: 'runtime_unsupported' as const,
retryable: false as const,
};
/**
* Resolves the one live {@link ChatRuntime} for this process and enforces the
* `pi-rpc` readiness preconditions at module init — before the gateway accepts
* traffic. It never falls back from `pi-rpc` to embedded execution: an unmet
* `pi-rpc` precondition is a typed startup failure ({@link ChatRuntimeUnavailableError}),
* and until `onModuleInit` selects a runtime, {@link active} throws rather than
* exposing any runtime — a failed `pi-rpc` init can never leak the embedded one.
*/
@Injectable()
export class ChatRuntimeRouter implements OnModuleInit, LegacyEmbeddedChatPort {
private readonly mode: ChatRuntimeMode;
/** The single resolved runtime. Undefined until a successful `onModuleInit`. */
private resolved: ChatRuntime | undefined;
constructor(
private readonly harnessRegistry: HarnessRegistry,
private readonly conversationService: HarnessConversationServiceBinding,
private readonly embedded: ChatRuntime,
private readonly harness: ChatRuntime,
mode: ChatRuntimeMode = resolveChatRuntimeMode(),
) {
this.mode = mode;
}
onModuleInit(): void {
if (this.mode === 'legacy') {
// Legacy ignores the pi-rpc preconditions entirely and always runs embedded.
this.resolved = this.embedded;
return;
}
// pi-rpc: both preconditions are hard startup failures, checked in a fixed order.
if (!this.harnessRegistry.has('pi')) {
this.resolved = undefined;
throw new ChatRuntimeUnavailableError('adapter_unavailable');
}
if (!isHarnessConversationServiceAvailable(this.conversationService)) {
this.resolved = undefined;
throw new ChatRuntimeUnavailableError('conversation_service_unavailable');
}
this.resolved = this.harness;
}
get active(): ChatRuntime {
if (this.resolved === undefined) {
// Reached only if init has not run or failed closed; never expose a runtime here.
throw new Error('The chat runtime is not available: startup did not resolve a runtime.');
}
return this.resolved;
}
/**
* The process-wide mode, available before {@link onModuleInit}. Production handlers read
* this to fail a legacy browser turn closed under `pi-rpc` *before* parsing the payload as
* either browser-legacy input or a Discord envelope — never to branch into a fallback.
*/
get runtimeMode(): ChatRuntimeMode {
return this.mode;
}
/**
* The embedded runtime narrowed to its port. Only reached on the legacy path (and for the
* verified-Discord op in both modes), where the injected runtime is always a real
* `EmbeddedChatRuntime`. The router spec constructs the router with a bare `{ kind }` stub
* but never invokes a port op, so this narrowing is never exercised against the stub.
*/
private get embeddedPort(): LegacyEmbeddedChatPort {
return this.embedded as unknown as LegacyEmbeddedChatPort;
}
// --- LegacyEmbeddedChatPort: legacy browser operations fail closed under pi-rpc ---
completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.completeLegacyRestTurn(context, input);
}
prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.prepareLegacySocketTurn(context, input, stream);
}
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.setLegacyThinking(context, level);
}
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.abortLegacyTurn(context);
}
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.applyLegacyModelOverride(context, modelId);
}
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.readLegacySessionPresentation(context);
}
/**
* Verified Discord ingress bypasses browser mode: it is embedded-only in BOTH modes and
* never reaches the harness or routing-engine selection. It is reached only through a
* {@link VerifiedDiscordIngressContext}, which exists only after every ingress check.
*/
dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream);
}
}
-273
View File
@@ -1,273 +0,0 @@
import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types';
/**
* The single chat execution strategy resolved by {@link ChatRuntimeRouter}.
*
* Exactly one runtime is live per process. There is no union that lets a
* `pi-rpc` deployment silently fall back to embedded execution: an unmet
* `pi-rpc` precondition is a typed startup failure, never a downgrade.
*/
export type ChatRuntimeMode = 'legacy' | 'pi-rpc';
export type ChatRuntimeKind = 'embedded' | 'harness';
/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */
export interface ChatRuntime {
readonly kind: ChatRuntimeKind;
}
/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */
export type ChatRuntimeUnavailableReason =
| 'adapter_unavailable'
| 'conversation_service_unavailable';
/**
* Raised at module init when `pi-rpc` mode is selected but its preconditions are
* unmet. Carries only fixed, browser-safe text — never a raw exception message,
* stack, or provider detail — and reports the frozen ack code `runtime_unsupported`.
*/
export class ChatRuntimeUnavailableError extends Error {
readonly code = 'runtime_unsupported' as const;
readonly reason: ChatRuntimeUnavailableReason;
constructor(reason: ChatRuntimeUnavailableReason) {
super(
reason === 'adapter_unavailable'
? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.'
: 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.',
);
this.name = 'ChatRuntimeUnavailableError';
this.reason = reason;
}
}
/**
* Resolves the process-wide chat runtime mode from the environment. Anything other
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
*/
export function resolveChatRuntimeMode(
env: Record<string, string | undefined> = process.env,
): ChatRuntimeMode {
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
}
// ---------------------------------------------------------------------------
// Transitional embedded chat port (Task Five).
//
// The legacy embedded browser behaviour is moved behind this exact interface so
// neither the controller nor the gateway retains AgentService, RoutingEngine,
// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime`
// implements the port; `ChatRuntimeRouter` exposes the same narrowly named
// operations and returns `runtime_unsupported` before touching Embedded for legacy
// browser operations when the mode is `pi-rpc`.
//
// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion,
// legacy Socket streaming, P3 harness turns, and verified Discord are distinct
// transport/trust capabilities — there is deliberately no generic
// `sendConversationTurn` nor an AgentService-shaped mirror on the router.
// ---------------------------------------------------------------------------
/**
* Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser
* DTOs are never structurally assignable to it. The factory that mints one may be called
* only after authentication with `scopeFromUser(...)`, never with payload authority fields.
*/
declare const ownedConversationContextBrand: unique symbol;
/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */
export interface OwnedConversationContext {
readonly [ownedConversationContextBrand]: true;
readonly conversationId: string;
readonly scope: Readonly<{ userId: string; tenantId: string }>;
}
/**
* Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned
* conversations all collapse to `conversation_unavailable`. Ownership/mode/validation
* failures are total results and never throw.
*/
export type LegacyRuntimeFailure =
| { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false }
| { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false }
| { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false }
| {
readonly ok: false;
readonly code: 'thinking_level_invalid';
readonly retryable: false;
readonly availableThinkingLevels: readonly string[];
}
| { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true }
| { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false }
| { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean }
| { readonly ok: false; readonly code: 'timeout'; readonly retryable: true };
/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */
export type LegacyRuntimeResult<T> =
| { readonly ok: true; readonly value: T }
| LegacyRuntimeFailure;
/** User-facing session projection. Carries no session object, handle, or credential path. */
export interface LegacySessionPresentation {
readonly provider: string;
readonly modelId: string;
readonly thinkingLevel: string;
readonly availableThinkingLevels: readonly string[];
readonly agentName?: string;
readonly routingDecision?: RoutingDecisionInfo;
}
/** Terminal usage stats, normalized by Embedded from AgentService metrics. */
export interface LegacyUsage {
readonly provider: string;
readonly modelId: string;
readonly thinkingLevel: string;
readonly tokens: Readonly<{
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
}>;
readonly cost: number;
readonly context: Readonly<{ percent: number | null; window: number }>;
}
/**
* Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw
* exception, tool arguments, or credential-bearing path — the gateway sees only these.
*/
export type LegacyRuntimeEvent =
| { readonly type: 'started' }
| { readonly type: 'text_delta'; readonly text: string }
| { readonly type: 'thinking_delta'; readonly text: string }
| {
readonly type: 'tool_started';
readonly toolCallId: string;
readonly toolName: string;
}
| {
readonly type: 'tool_finished';
readonly toolCallId: string;
readonly toolName: string;
readonly isError: boolean;
}
| { readonly type: 'settled'; readonly usage?: LegacyUsage };
/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */
export interface LegacyBrowserMessagePayload {
readonly content: string;
readonly provider?: string;
readonly modelId?: string;
readonly agentId?: string;
readonly attachments?: readonly ChannelAttachmentDto[];
}
/** A prepared-but-not-yet-dispatched legacy socket turn. */
export interface LegacySocketTurnLease {
readonly presentation: LegacySessionPresentation;
/**
* Atomically one-shot and scope-rechecking. A second call returns
* `turn_already_dispatched` and performs zero prompt/tool effects.
*/
dispatch(): Promise<LegacyRuntimeResult<void>>;
/** Idempotent, non-throwing. Removes listener and channel, including partial setup. */
dispose(): Promise<void>;
}
/**
* Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token
* auth plus signature, allowlist, binding, expected-route, replay, configured-agent,
* forced-scope, and attachment-normalization checks.
*/
declare const verifiedDiscordIngressContextBrand: unique symbol;
/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */
export interface VerifiedDiscordIngressContext {
readonly [verifiedDiscordIngressContextBrand]: true;
readonly conversationId: string;
readonly scope: Readonly<{ userId: string; tenantId: string }>;
readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>;
readonly content: string;
readonly attachments?: readonly ChannelAttachmentDto[];
readonly correlationId: string;
readonly discordMessageId: string;
readonly discordUserId: string;
}
/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */
export interface VerifiedDiscordTurnLease {
readonly presentation: LegacySessionPresentation;
dispatch(): Promise<LegacyRuntimeResult<void>>;
dispose(): Promise<void>;
}
/** Server-owned egress projection the runtime pushes normalized events into. */
export interface LegacyRuntimeStream {
/** Server-derived, e.g. `websocket:<socket-id>`. Never client-supplied. */
readonly channelId: string;
onEvent(event: LegacyRuntimeEvent): void;
}
/**
* The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter`
* mirrors the operation names and fails closed with `runtime_unsupported` for legacy
* browser operations under `pi-rpc`.
*/
export interface LegacyEmbeddedChatPort {
completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
>;
prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>>;
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation>;
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>>;
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation>;
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation>;
dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>>;
}
/**
* Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass
* a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied
* authority field. The brand is phantom, so this is the only way to obtain the branded type.
*/
export function ownConversation(
conversationId: string,
scope: Readonly<{ userId: string; tenantId: string }>,
): OwnedConversationContext {
return { conversationId, scope } as unknown as OwnedConversationContext;
}
/**
* Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every
* ingress check (service-token auth, signature, allowlist, binding, expected-route, replay,
* configured-agent, forced-scope, attachment normalization) before calling this.
*/
export function verifyDiscordIngress(
fields: Omit<VerifiedDiscordIngressContext, typeof verifiedDiscordIngressContextBrand>,
): VerifiedDiscordIngressContext {
return { ...fields } as unknown as VerifiedDiscordIngressContext;
}
+63 -32
View File
@@ -3,20 +3,21 @@ import {
Post,
Body,
Logger,
ForbiddenException,
HttpException,
HttpStatus,
NotFoundException,
Inject,
UseGuards,
} from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { Throttle } from '@nestjs/throttler';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { v4 as uuid } from 'uuid';
import { ChatRequestDto } from './chat.dto.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import { ownConversation } from './chat-runtime.js';
import type { LegacyRuntimeFailure } from './chat-runtime.js';
interface ChatResponse {
conversationId: string;
@@ -28,7 +29,7 @@ interface ChatResponse {
export class ChatController {
private readonly logger = new Logger(ChatController.name);
constructor(private readonly runtime: ChatRuntimeRouter) {}
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Post()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@@ -39,38 +40,68 @@ export class ChatController {
const conversationId = body.conversationId ?? uuid();
const scope = scopeFromUser(user);
try {
let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
});
}
} catch (err) {
if (err instanceof ForbiddenException) {
throw new NotFoundException('Session not found');
}
this.logger.error(
`Session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
}
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
// The one exclusive runtime owns execution. In legacy mode this reaches the embedded runtime;
// in pi-rpc it fails closed with `runtime_unsupported` before ever touching embedded execution.
const result = await this.runtime.completeLegacyRestTurn(
ownConversation(conversationId, scope),
{ content: body.content },
);
let responseText = '';
if (result.ok) {
return { conversationId, text: result.value.text };
const done = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
reject(new Error('Agent response timed out'));
}, 120_000);
const cleanup = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
if (
event.type === 'message_update' &&
event.assistantMessageEvent.type === 'text_delta'
) {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
clearTimeout(timer);
cleanup();
resolve();
}
},
scope,
);
});
try {
await this.agentService.prompt(conversationId, body.content, scope);
await done;
} catch (err) {
if (err instanceof HttpException) throw err;
const message = err instanceof Error ? err.message : String(err);
if (message.includes('timed out')) {
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
}
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
throw this.toHttpException(result, conversationId);
}
/** Maps a total {@link LegacyRuntimeFailure} to the fixed browser-safe HTTP surface. */
private toHttpException(failure: LegacyRuntimeFailure, conversationId: string): HttpException {
switch (failure.code) {
case 'conversation_unavailable':
return new NotFoundException('Session not found');
case 'request_invalid':
case 'thinking_level_invalid':
return new HttpException('Invalid chat request', HttpStatus.BAD_REQUEST);
case 'timeout':
return new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
case 'runtime_unsupported':
case 'runtime_unavailable':
return new HttpException('Agent runtime unavailable', HttpStatus.SERVICE_UNAVAILABLE);
default:
this.logger.error(`Chat turn failed for conversation=${conversationId}: ${failure.code}`);
return new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
return { conversationId, text: responseText };
}
}
+1 -63
View File
@@ -1,14 +1,5 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { Transform, Type } from 'class-transformer';
import {
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto {
@IsOptional()
@@ -46,56 +37,3 @@ export class ChatSocketMessageDto {
/** Validated channel attachment references; binary content is not embedded. */
attachments?: readonly ChannelAttachmentDto[];
}
/**
* Task Five, group 2 — the frozen pi-rpc `turn:send` selection triple.
*
* Each id is a required, non-empty, bounded string. There is no `@IsOptional` and no extra
* field: under `forbidNonWhitelisted` an unknown selection key is rejected, and a missing id
* fails `@IsString` (undefined is not a string) rather than silently passing.
*/
export class HarnessTurnSelectionDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
harnessId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
providerId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
modelId!: string;
}
/**
* Task Five, group 2 — the frozen wire contract for a pi-rpc `turn:send`.
*
* Validated through the production `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`:
* a UUID conversation id; `content` trimmed then bounded to 1..10_000 characters (whitespace-only
* collapses to empty and fails `@IsNotEmpty`); a nested `selection` object recursed with an
* explicit `@Type` (a bare `@ValidateNested` is masked green by class-validator's empty-metadata
* `unknownValue`); and a UUID-v4 idempotency key. No `provider`/`modelId`/`attachments` or other
* authority field is declared, so `forbidNonWhitelisted` rejects every unknown top-level key.
*/
export class HarnessTurnSendDto {
@IsUUID()
conversationId!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty()
@MaxLength(10_000)
content!: string;
@IsObject()
@ValidateNested()
@Type(() => HarnessTurnSelectionDto)
selection!: HarnessTurnSelectionDto;
@IsUUID('4')
idempotencyKey!: string;
}
@@ -8,31 +8,12 @@ const payload: SlashCommandPayload = {
approvalId: 'approval-1',
};
/**
* Task 5 fence (F, existing control): gateway-owned command authorization/approval must
* cause ZERO chat-runtime dispatch. Placed in the gateway's chat-runtime-router slot (the
* former direct `AgentService` slot) so any accidental chat-runtime resolution throws
* loudly instead of silently passing. Because execute/approval run entirely through the
* command executor dependency and never resolve a chat runtime, this fixture is never
* triggered and the ingress stays a GREEN control.
*/
function failIfUsedChatRuntimeRouter() {
return {
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the command approval path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the command approval path');
},
};
}
function buildGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
return new ChatGateway(
failIfUsedChatRuntimeRouter() as never,
{} as never,
{} as never,
{} as never,
{} as never,
@@ -91,114 +72,3 @@ describe('ChatGateway command approval ingress', () => {
});
});
});
/**
* Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so
* embedded slash-commands (/model, /agent, and every other non-audited command) are fixed
* "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent
* fall-through to embedded execution. Only runtime-independent audited system commands
* (/reload) pass through as a positive control, and the approval path stays runtime-independent.
* The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so
* a fence bypass surfaces as a thrown error rather than a silent embedded dispatch.
*/
function buildPiRpcGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
const piRpcRouter = {
runtimeMode: 'pi-rpc' as const,
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the pi-rpc command path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the pi-rpc command path');
},
};
return new ChatGateway(
piRpcRouter as never,
{} as never,
{} as never,
{} as never,
commandExecutor as never,
{} as never,
);
}
describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => {
const UNSUPPORTED = 'Slash commands are not available on this deployment.';
it.each(['model', 'agent', 'gc'])(
'fails /%s closed before the executor under pi-rpc (execute never called)',
async (command): Promise<void> => {
const commandExecutor = {
execute: vi
.fn()
.mockResolvedValue({ command, conversationId: 'conversation-1', success: true }),
createApproval: vi.fn(),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, {
command,
conversationId: 'conversation-1',
});
expect(commandExecutor.execute).toHaveBeenCalledTimes(0);
expect(client.emit).toHaveBeenCalledWith('command:result', {
command,
conversationId: 'conversation-1',
success: false,
message: UNSUPPORTED,
});
},
);
it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise<void> => {
const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true };
const commandExecutor = {
execute: vi.fn().mockResolvedValue(reloadResult),
createApproval: vi.fn(),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, {
command: 'reload',
conversationId: 'conversation-1',
});
expect(commandExecutor.execute).toHaveBeenCalledTimes(1);
expect(commandExecutor.execute).toHaveBeenCalledWith(
{ command: 'reload', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult);
});
it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn(),
createApproval: vi.fn().mockResolvedValue({
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
}),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandApproval(client as never, {
command: 'gc',
conversationId: 'conversation-1',
});
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
{ command: 'gc', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith(
'command:approval',
expect.objectContaining({ success: true, approvalId: 'approval-1' }),
);
});
});
Binary file not shown.
File diff suppressed because it is too large Load Diff
+3 -50
View File
@@ -1,59 +1,12 @@
import { forwardRef, Module } from '@nestjs/common';
import { CommandsModule } from '../commands/commands.module.js';
import { HarnessModule } from '../harness/harness.module.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_REGISTRY,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import type { HarnessConversationService } from '@mosaicstack/types';
import { ChatGateway } from './chat.gateway.js';
import { ChatController } from './chat.controller.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import { EmbeddedChatRuntime } from './embedded-chat.runtime.js';
import { HarnessChatRuntime } from './harness-chat.runtime.js';
/**
* Task Five wiring. The exclusive {@link ChatRuntimeRouter} is the single chat-execution
* authority: the controller and gateway inject only the router, never `AgentService`,
* `RoutingEngineService`, or a session/`piSession` handle. The router resolves exactly one
* runtime at module init — {@link EmbeddedChatRuntime} in legacy mode, {@link HarnessChatRuntime}
* in `pi-rpc` — over the REAL {@link HarnessModule} registry and conversation-service binding.
*
* The router and the harness runtime are constructed through factories because their
* dependencies are interface/union types with no runtime injection token (the registry and
* conversation-service arrive via the string tokens exported by `HarnessModule`); the embedded
* runtime injects the class-typed `AgentService` and is provided directly.
*/
@Module({
imports: [forwardRef(() => CommandsModule), HarnessModule],
imports: [forwardRef(() => CommandsModule)],
controllers: [ChatController],
providers: [
ChatGateway,
EmbeddedChatRuntime,
{
provide: HarnessChatRuntime,
useFactory: (conversationService: HarnessConversationServiceBinding) =>
new HarnessChatRuntime(conversationService as HarnessConversationService),
inject: [HARNESS_CONVERSATION_SERVICE],
},
{
provide: ChatRuntimeRouter,
useFactory: (
registry: HarnessRegistry,
conversationService: HarnessConversationServiceBinding,
embedded: EmbeddedChatRuntime,
harness: HarnessChatRuntime,
) => new ChatRuntimeRouter(registry, conversationService, embedded, harness),
inject: [
HARNESS_REGISTRY,
HARNESS_CONVERSATION_SERVICE,
EmbeddedChatRuntime,
HarnessChatRuntime,
],
},
],
exports: [ChatGateway, ChatRuntimeRouter],
providers: [ChatGateway],
exports: [ChatGateway],
})
export class ChatModule {}
@@ -1,532 +0,0 @@
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { AgentService, type AgentSession } from '../agent/agent.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
import type {
ChatRuntime,
LegacyBrowserMessagePayload,
LegacyEmbeddedChatPort,
LegacyRuntimeEvent,
LegacyRuntimeResult,
LegacySessionPresentation,
LegacySocketTurnLease,
LegacyUsage,
OwnedConversationContext,
VerifiedDiscordIngressContext,
VerifiedDiscordTurnLease,
LegacyRuntimeStream,
} from './chat-runtime.js';
/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */
const REST_TURN_TIMEOUT_MS = 120_000;
/**
* The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}.
*
* It owns the embedded in-process execution path — the `AgentService` stack that the
* `ChatController` and `ChatGateway` drove directly before Task Five. Once the
* {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser
* HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so
* neither the controller nor the gateway retains `AgentService`, `piSession`, session,
* listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by
* `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation
* collapses to `conversation_unavailable` and never throws out of the port.
*/
@Injectable()
export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort {
readonly kind = 'embedded' as const;
private readonly logger = new Logger(EmbeddedChatRuntime.name);
constructor(readonly agentService: AgentService) {}
// -------------------------------------------------------------------------
// Legacy REST completion (op A)
// -------------------------------------------------------------------------
async completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(conversationId, scope, {});
if (!resolved.ok) return resolved;
let responseText = '';
let timer: ReturnType<typeof setTimeout> | undefined;
let detach: (() => void) | undefined;
let disposed = false;
// One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and
// detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt
// rejection fires first. Without this, a prompt() rejection surfaced through the catch below
// would return while leaving the listener attached (free to consume a later turn's events) and
// the 120s timer live (its rejection later going unobserved).
const dispose = (): void => {
if (disposed) return;
disposed = true;
if (timer !== undefined) clearTimeout(timer);
detach?.();
};
const done = new Promise<void>((resolve, reject) => {
timer = setTimeout(() => {
dispose();
reject(new Error('Agent response timed out'));
}, REST_TURN_TIMEOUT_MS);
detach = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
if (
event.type === 'message_update' &&
event.assistantMessageEvent.type === 'text_delta'
) {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
dispose();
resolve();
}
},
scope,
);
});
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
const prompting = this.agentService.prompt(conversationId, input.content, scope);
try {
await Promise.all([prompting, done]);
} catch (err) {
dispose();
const message = err instanceof Error ? err.message : String(err);
if (message.includes('timed out')) {
return { ok: false, code: 'timeout', retryable: true };
}
this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message);
return { ok: false, code: 'operation_failed', retryable: false };
}
const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation;
return { ok: true, value: { text: responseText, presentation } };
}
// -------------------------------------------------------------------------
// Legacy Socket streaming (op B)
// -------------------------------------------------------------------------
async prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(conversationId, scope, {
...(input.provider ? { provider: input.provider } : {}),
...(input.modelId ? { modelId: input.modelId } : {}),
...(input.agentId ? { agentConfigId: input.agentId } : {}),
});
if (!resolved.ok) return resolved;
let detach: () => void;
try {
detach = this.subscribe(conversationId, scope, stream);
} catch (err) {
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
this.logger.error(
`Embedded socket subscription failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
return {
ok: true,
value: this.buildLease(
conversationId,
scope,
input.content,
input.attachments,
detach,
resolved.presentation,
),
};
}
// -------------------------------------------------------------------------
// Thinking level (op C) — synchronous, total
// -------------------------------------------------------------------------
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
const availableThinkingLevels = session.piSession.getAvailableThinkingLevels();
if (!(availableThinkingLevels as readonly string[]).includes(level)) {
return {
ok: false,
code: 'thinking_level_invalid',
retryable: false,
availableThinkingLevels,
};
}
session.piSession.setThinkingLevel(level as never);
return { ok: true, value: this.presentationForSession(session) };
}
// -------------------------------------------------------------------------
// Abort (op D)
// -------------------------------------------------------------------------
async abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
try {
await session.piSession.abort();
} catch (err) {
this.logger.error(
`Legacy abort failed for conversation=${context.conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'operation_failed', retryable: false };
}
return { ok: true, value: undefined };
}
// -------------------------------------------------------------------------
// Model override (synchronous, total)
// -------------------------------------------------------------------------
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
this.agentService.updateSessionModel(context.conversationId, modelId, scope);
const refreshed = this.agentService.getSession(context.conversationId, scope) ?? session;
return { ok: true, value: this.presentationForSession(refreshed) };
}
// -------------------------------------------------------------------------
// Presentation read (synchronous, total)
// -------------------------------------------------------------------------
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
return { ok: true, value: this.presentationForSession(session) };
}
// -------------------------------------------------------------------------
// Verified Discord ingress (embedded-only in both modes)
// -------------------------------------------------------------------------
async dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(
conversationId,
scope,
{ agentConfigId: context.configuredAgent.agentConfigId },
{
agentConfigId: context.configuredAgent.agentConfigId,
instanceId: context.configuredAgent.instanceId,
},
);
if (!resolved.ok) return resolved;
let detach: () => void;
try {
detach = this.subscribe(conversationId, scope, stream);
} catch (err) {
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
this.logger.error(
`Embedded Discord subscription failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
return {
ok: true,
value: this.buildLease(
conversationId,
scope,
context.content,
context.attachments,
detach,
resolved.presentation,
),
};
}
// -------------------------------------------------------------------------
// Shared helpers
// -------------------------------------------------------------------------
/**
* Resolves the owned session, creating it on first use. Ownership/scope rejections
* (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation
* failure surfaces as the retryable `runtime_unavailable`. On success returns the
* session presentation so callers avoid a redundant `getSession`.
*/
private async resolveOrCreate(
conversationId: string,
scope: ActorTenantScope,
extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>,
expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>,
): Promise<
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
> {
// A verified-Discord turn may only run under a session whose configured identity matches the
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
// pre-existing session AND a freshly created one: a session carrying a different configured
// agent — however it arose — is rejected rather than executed under the verified label, so we
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
// no expectedAgent and skips the check.
const identityMatches = (candidate: AgentSession): boolean =>
expectedAgent === undefined ||
(candidate.agentConfigId === expectedAgent.agentConfigId &&
candidate.agentName === expectedAgent.instanceId);
let session = this.agentService.getSession(conversationId, scope);
if (session && !identityMatches(session)) {
// Reused same-scope session minted under a different configured identity — reject with zero
// effects rather than dispatch a verified turn onto a foreign agent's session.
return CONVERSATION_UNAVAILABLE;
}
if (!session) {
try {
session = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
...extraOptions,
});
} catch (err) {
if (err instanceof ForbiddenException || err instanceof NotFoundException) {
return CONVERSATION_UNAVAILABLE;
}
this.logger.error(
`Embedded session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
// The just-created session must ALSO carry the reconciled identity before any effect. A
// createSession that returns a session under a different configured agent (misconfiguration
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
if (!identityMatches(session)) {
return CONVERSATION_UNAVAILABLE;
}
}
return { ok: true, presentation: this.presentationForSession(session) };
}
/** Installs a normalizing event listener that forwards to the server-owned stream. */
private subscribe(
conversationId: string,
scope: ActorTenantScope,
stream: LegacyRuntimeStream,
): () => void {
const unsubscribe = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
const normalized = this.normalizeEvent(conversationId, scope, event);
if (normalized) stream.onEvent(normalized);
},
scope,
);
try {
this.agentService.addChannel(conversationId, stream.channelId, scope);
} catch (err) {
// Partial setup: the listener was acquired but the channel attach failed. Roll back
// exactly what was acquired (the listener) before the failure escapes, so no leaked
// subscription survives; the caller converts the rethrow into a total safe failure.
try {
unsubscribe();
} catch {
/* idempotent teardown */
}
throw err;
}
return () => {
try {
unsubscribe();
} catch {
/* idempotent teardown */
}
try {
this.agentService.removeChannel(conversationId, stream.channelId, scope);
} catch {
/* idempotent teardown */
}
};
}
/** Builds an atomically one-shot, scope-rechecking dispatch lease. */
private buildLease(
conversationId: string,
scope: ActorTenantScope,
content: string,
attachments: VerifiedDiscordIngressContext['attachments'],
detach: () => void,
presentation: LegacySessionPresentation,
): LegacySocketTurnLease & VerifiedDiscordTurnLease {
let dispatched = false;
let disposed = false;
return {
presentation,
dispatch: async (): Promise<LegacyRuntimeResult<void>> => {
if (dispatched) {
return { ok: false, code: 'turn_already_dispatched', retryable: false };
}
dispatched = true;
try {
await this.agentService.prompt(conversationId, content, scope, attachments);
} catch (err) {
this.logger.error(
`Legacy dispatch failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'operation_failed', retryable: false };
}
return { ok: true, value: undefined };
},
dispose: async (): Promise<void> => {
if (disposed) return;
disposed = true;
detach();
},
};
}
/** Normalizes a raw agent event into the redaction-agnostic transport event, or drops it. */
private normalizeEvent(
conversationId: string,
scope: ActorTenantScope,
event: AgentSessionEvent,
): LegacyRuntimeEvent | undefined {
switch (event.type) {
case 'agent_start':
return { type: 'started' };
case 'agent_end':
return { type: 'settled', ...this.usageFor(conversationId, scope) };
case 'message_update': {
const assistant = event.assistantMessageEvent;
if (assistant.type === 'text_delta') return { type: 'text_delta', text: assistant.delta };
if (assistant.type === 'thinking_delta') {
return { type: 'thinking_delta', text: assistant.delta };
}
return undefined;
}
case 'tool_execution_start':
return { type: 'tool_started', toolCallId: event.toolCallId, toolName: event.toolName };
case 'tool_execution_end':
return {
type: 'tool_finished',
toolCallId: event.toolCallId,
toolName: event.toolName,
isError: event.isError,
};
default:
return undefined;
}
}
/**
* Gathers terminal usage from the Pi session and records it into session metrics.
* Embedded owns AgentService metrics; the gateway never touches `piSession` stats.
*/
private usageFor(conversationId: string, scope: ActorTenantScope): { usage?: LegacyUsage } {
const session = this.agentService.getSession(conversationId, scope);
const piSession = session?.piSession;
const stats = piSession?.getSessionStats();
if (!session || !stats) return {};
const contextUsage = piSession?.getContextUsage();
const tokens = {
input: stats.tokens?.input ?? 0,
output: stats.tokens?.output ?? 0,
cacheRead: stats.tokens?.cacheRead ?? 0,
cacheWrite: stats.tokens?.cacheWrite ?? 0,
total: stats.tokens?.total ?? 0,
};
this.agentService.recordTokenUsage(conversationId, { ...tokens });
return {
usage: {
provider: session.provider,
modelId: session.modelId,
thinkingLevel: piSession?.thinkingLevel ?? 'off',
tokens,
cost: stats.cost ?? 0,
context: {
percent: contextUsage?.percent ?? null,
window: contextUsage?.contextWindow ?? 0,
},
},
};
}
/** Presentation from a live session id, or undefined when no owned session exists. */
private presentationFor(
conversationId: string,
scope: ActorTenantScope,
): LegacySessionPresentation | undefined {
const session = this.agentService.getSession(conversationId, scope);
return session ? this.presentationForSession(session) : undefined;
}
/** User-facing projection carrying no session handle, credential, or raw stats. */
private presentationForSession(session: AgentSession): LegacySessionPresentation {
return {
provider: session.provider,
modelId: session.modelId,
thinkingLevel: session.piSession.thinkingLevel,
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
...(session.agentName ? { agentName: session.agentName } : {}),
};
}
}
/** The shared terminal `conversation_unavailable` failure (missing/foreign/lost ownership). */
const CONVERSATION_UNAVAILABLE = {
ok: false as const,
code: 'conversation_unavailable' as const,
retryable: false as const,
};
/** Narrows a branded context scope to the `AgentService` actor/tenant scope (identical shape). */
function toScope(scope: Readonly<{ userId: string; tenantId: string }>): ActorTenantScope {
return { userId: scope.userId, tenantId: scope.tenantId };
}
@@ -1,170 +0,0 @@
import { describe, expect, it } from 'vitest';
import type {
AttachConversation,
ConversationSnapshot,
DetachConversation,
HarnessActorContext,
HarnessConversationService,
HarnessEventEnvelope,
HarnessSelection,
SendHarnessTurn,
TurnReceipt,
} from '@mosaicstack/types';
import { HarnessChatRuntime } from './harness-chat.runtime.js';
/**
* Task Five, Step One (harness runtime). Proves the `pi-rpc` runtime executes
* exclusively through the {@link HarnessConversationService} RPC boundary and
* forwards the caller's exact selection tuple and idempotency key without
* substitution. Red-first: the runtime is an unimplemented stub, so every
* delegation assertion fails until Step Three.
*/
const context: HarnessActorContext = {
actorId: 'actor-1',
tenantId: 'tenant-1',
seatId: 'seat-1',
correlationId: 'corr-1',
};
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude-opus-4-8',
};
const conversationId = '11111111-1111-4111-8111-111111111111';
const idempotencyKey = '22222222-2222-4222-8222-222222222222';
const sendInput: SendHarnessTurn & { idempotencyKey: string } = {
context,
conversationId,
selection,
turnId: 'turn-abc',
correlationId: 'corr-1',
content: 'hello',
idempotencyKey,
};
const attachInput: AttachConversation & { afterSequence?: number } = {
context,
conversationId,
clientId: 'client-1',
selection,
afterSequence: 0,
};
const detachInput: DetachConversation = {
context,
conversationId,
clientId: 'client-1',
};
interface RecordedCalls {
attach: (AttachConversation & { afterSequence?: number })[];
detach: DetachConversation[];
send: (SendHarnessTurn & { idempotencyKey: string })[];
subscribeFrom: { conversationId: string; afterSequence: number }[];
}
const snapshot: ConversationSnapshot = {
session: {
conversationId,
nativeSessionId: 'native-1',
seatId: 'seat-1',
selection,
state: 'idle',
attachedClientIds: ['client-1'],
},
lastSequence: 0,
replay: [],
};
function build(): { runtime: HarnessChatRuntime; calls: RecordedCalls } {
const calls: RecordedCalls = { attach: [], detach: [], send: [], subscribeFrom: [] };
const service: HarnessConversationService = {
attach: (input) => {
calls.attach.push(input);
return Promise.resolve(snapshot);
},
detach: (input) => {
calls.detach.push(input);
return Promise.resolve();
},
send: (input) => {
calls.send.push(input);
// The service echoes only the requested tuple; there is no representable substitute.
const receipt: TurnReceipt = {
conversationId: input.conversationId,
turnId: 'turn-server',
correlationId: input.correlationId,
state: 'accepted',
selection: input.selection,
};
return Promise.resolve(receipt);
},
subscribeFrom: (id, afterSequence) => {
calls.subscribeFrom.push({ conversationId: id, afterSequence });
return (async function* (): AsyncIterable<HarnessEventEnvelope> {
return;
})();
},
};
return { runtime: new HarnessChatRuntime(service), calls };
}
describe('HarnessChatRuntime', () => {
it('is the harness runtime kind and needs only a HarnessConversationService', () => {
const { runtime } = build();
expect(runtime.kind).toBe('harness');
});
it('delegates send to the conversation service with the exact tuple and idempotency key', async () => {
const { runtime, calls } = build();
const receipt = await runtime.send(sendInput);
expect(calls.send).toHaveLength(1);
const firstSend = calls.send[0]!;
expect(firstSend).toEqual(sendInput);
expect(firstSend.idempotencyKey).toBe(idempotencyKey);
expect(firstSend.selection).toEqual(selection);
// The runtime must not substitute an effective tuple onto the receipt.
expect(receipt.selection).toEqual(selection);
});
it('delegates attach to the conversation service and returns its snapshot', async () => {
const { runtime, calls } = build();
const result = await runtime.attach(attachInput);
expect(calls.attach).toHaveLength(1);
expect(calls.attach[0]).toEqual(attachInput);
expect(result).toBe(snapshot);
});
it('delegates detach to the conversation service', async () => {
const { runtime, calls } = build();
await runtime.detach(detachInput);
expect(calls.detach).toHaveLength(1);
expect(calls.detach[0]).toEqual(detachInput);
});
it('delegates subscribeFrom to the conversation service journal replay', async () => {
const { runtime, calls } = build();
const iterable = runtime.subscribeFrom(conversationId, 7);
// Drain to prove it is the service-backed async iterable, not a fabricated one.
const drained: unknown[] = [];
for await (const event of iterable) {
drained.push(event);
}
expect(drained).toHaveLength(0);
expect(calls.subscribeFrom).toHaveLength(1);
expect(calls.subscribeFrom[0]).toEqual({ conversationId, afterSequence: 7 });
});
});
@@ -1,47 +0,0 @@
import type {
AttachConversation,
ConversationSnapshot,
DetachConversation,
HarnessConversationService,
HarnessEventEnvelope,
SendHarnessTurn,
TurnReceipt,
} from '@mosaicstack/types';
import type { ChatRuntime } from './chat-runtime.js';
/**
* The `pi-rpc` chat runtime. It executes browser chat exclusively through the
* harness-neutral {@link HarnessConversationService} RPC boundary — it never
* touches the embedded `AgentService`/`ProviderService`/`RoutingEngineService`
* stack, and it forwards the caller's exact selection tuple and idempotency key
* without substitution.
*
* It owns no state and adds no policy: every method forwards the caller's exact
* argument to the injected {@link HarnessConversationService} and returns its
* result unchanged, so the requested selection tuple and idempotency key can
* never be substituted on the way through.
*/
export class HarnessChatRuntime implements ChatRuntime {
readonly kind = 'harness' as const;
constructor(private readonly conversations: HarnessConversationService) {}
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot> {
return this.conversations.attach(input);
}
detach(input: DetachConversation): Promise<void> {
return this.conversations.detach(input);
}
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt> {
return this.conversations.send(input);
}
subscribeFrom(
conversationId: string,
afterSequence: number,
): AsyncIterable<HarnessEventEnvelope> {
return this.conversations.subscribeFrom(conversationId, afterSequence);
}
}
@@ -1,116 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChatRuntimeMode } from '../chat/chat-runtime.js';
import { ConversationsController } from './conversations.controller.js';
/**
* Task 5 harness fence for the conversations REST write path.
*
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
* legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a
* fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write.
* Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`.
*
* Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution,
* injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from
* `process.env` at request time. The two "env is flipped after construction" tests below are the
* load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn
* RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`.
*/
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
const USER = { id: 'user-1' };
function sendMessageDto() {
return {
role: 'user' as const,
content: 'hello from the legacy REST write path',
metadata: undefined,
};
}
function brainWithMessageSpy() {
const addMessage = vi.fn().mockResolvedValue({
id: 'message-1',
conversationId: CONVERSATION_ID,
role: 'user',
content: 'hello from the legacy REST write path',
});
return {
brain: { conversations: { addMessage } } as never,
addMessage,
};
}
/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */
function routerFixedTo(mode: ChatRuntimeMode) {
return { runtimeMode: mode };
}
let priorMode: string | undefined;
describe('conversations REST write path — Task 5 harness fence', () => {
beforeEach(() => {
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
});
afterEach(() => {
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
});
it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => {
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
await expect(
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
).rejects.toMatchObject({ code: 'runtime_unsupported' });
// Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be
// written, so no duplicate message can be produced.
expect(addMessage).not.toHaveBeenCalled();
});
it('writes through the repository when the router resolved legacy (GREEN control)', async () => {
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
expect(addMessage).toHaveBeenCalledWith(
{
conversationId: CONVERSATION_ID,
role: 'user',
content: 'hello from the legacy REST write path',
metadata: undefined,
},
USER.id,
);
expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID });
});
it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => {
// The runtime mode is fixed at module init. A later env mutation must not reopen the fence:
// a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write.
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
await expect(
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
).rejects.toMatchObject({ code: 'runtime_unsupported' });
expect(addMessage).not.toHaveBeenCalled();
});
it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => {
// Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a
// request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write.
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
expect(addMessage).toHaveBeenCalledTimes(1);
});
});
@@ -6,7 +6,6 @@ import {
ForbiddenException,
Get,
HttpCode,
HttpException,
HttpStatus,
Inject,
NotFoundException,
@@ -20,7 +19,6 @@ import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
import {
CreateConversationDto,
UpdateConversationDto,
@@ -28,41 +26,10 @@ import {
SearchMessagesDto,
} from './conversations.dto.js';
/**
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
* legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before
* the repository is touched — never a duplicate write. The `code` field is exposed at the top level
* so callers can discriminate the refusal while the 503 status carries the browser-safe surface.
*/
class HarnessRuntimeWriteUnsupportedException extends HttpException {
readonly code = 'runtime_unsupported' as const;
constructor() {
super(
{
code: 'runtime_unsupported',
message:
'Conversation message writes are handled by the harness runtime on this deployment.',
},
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
@Controller('api/conversations')
@UseGuards(AuthGuard)
export class ConversationsController {
/**
* `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3).
* The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at
* request time — a single source of truth, so the controller cannot disagree with the router
* about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode`
* so this class depends on nothing else the router exposes.
*/
constructor(
@Inject(BRAIN) private readonly brain: Brain,
@Inject(ChatRuntimeRouter) private readonly router: Pick<ChatRuntimeRouter, 'runtimeMode'>,
) {}
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list(@CurrentUser() user: { id: string }) {
@@ -127,13 +94,6 @@ export class ConversationsController {
@Body() dto: SendMessageDto,
@CurrentUser() user: { id: string },
) {
// Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the
// harness path owns persistence there, so a direct write would duplicate the message. The mode
// comes from the router's init-time resolution, not a request-time env read.
if (this.router.runtimeMode === 'pi-rpc') {
throw new HarnessRuntimeWriteUnsupportedException();
}
const message = await this.brain.conversations.addMessage(
{
conversationId: id,
@@ -1,14 +1,7 @@
import { Module } from '@nestjs/common';
import { ChatModule } from '../chat/chat.module.js';
import { ConversationsController } from './conversations.controller.js';
/**
* Imports {@link ChatModule} solely to inject its exported {@link ChatRuntimeRouter} into
* {@link ConversationsController}, so the REST write fence reads the same init-time runtime mode the
* router resolved — one source of truth, no duplicate provider, no global token, no AppModule edit.
*/
@Module({
imports: [ChatModule],
controllers: [ConversationsController],
})
export class ConversationsModule {}
+2 -11
View File
@@ -1,12 +1,7 @@
import { Module } from '@nestjs/common';
import { HarnessRegistry } from './harness.registry.js';
import { HarnessService } from './harness.service.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
HARNESS_REGISTRY,
HARNESS_SERVICE,
} from './harness.tokens.js';
import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js';
import { HarnessController } from './harness.controller.js';
import { HarnessSelectionController } from './harness-selection.controller.js';
import { HarnessSelectionService } from './harness-selection.service.js';
@@ -25,13 +20,9 @@ import { HarnessSelectionRepository } from './harness-selection.repository.js';
providers: [
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
{ provide: HARNESS_SERVICE, useClass: HarnessService },
// Task Five: bind the conversation-service token to its explicit "not yet bound"
// sentinel. The pi-rpc router treats this as a hard, typed startup failure; Task 14
// replaces it with a real service. Exported so ChatModule's router can inject it.
{ provide: HARNESS_CONVERSATION_SERVICE, useValue: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE },
HarnessSelectionRepository,
HarnessSelectionService,
],
exports: [HARNESS_REGISTRY, HARNESS_SERVICE, HARNESS_CONVERSATION_SERVICE],
exports: [HARNESS_REGISTRY, HARNESS_SERVICE],
})
export class HarnessModule {}
@@ -4,42 +4,8 @@
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
*/
import type { HarnessConversationService } from '@mosaicstack/types';
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
export type HarnessServiceToken = typeof HARNESS_SERVICE;
/**
* Token for the {@link HarnessConversationService} that {@link HarnessChatRuntime}
* depends on. Until Task 14 provides a real implementation, `HarnessModule` binds
* the {@link HARNESS_CONVERSATION_SERVICE_UNAVAILABLE} sentinel here, and the
* `pi-rpc` router treats that sentinel as a hard, typed startup failure.
*/
export const HARNESS_CONVERSATION_SERVICE = 'HARNESS_CONVERSATION_SERVICE' as const;
export type HarnessConversationServiceToken = typeof HARNESS_CONVERSATION_SERVICE;
/**
* Explicit "not yet bound" value for {@link HARNESS_CONVERSATION_SERVICE}. It is a
* distinct sentinel — never `null`/`undefined` — so an unbound service is an
* intentional, checkable state rather than an accidental nil that could read as
* "present". Replaced by a real service in Task 14.
*/
export const HARNESS_CONVERSATION_SERVICE_UNAVAILABLE: unique symbol = Symbol(
'HARNESS_CONVERSATION_SERVICE_UNAVAILABLE',
);
/** A binding for {@link HARNESS_CONVERSATION_SERVICE}: a real service or the sentinel. */
export type HarnessConversationServiceBinding =
| HarnessConversationService
| typeof HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
/** Narrows a binding to a usable service, excluding the unavailable sentinel. */
export function isHarnessConversationServiceAvailable(
binding: HarnessConversationServiceBinding,
): binding is HarnessConversationService {
return binding !== HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
}
@@ -12,10 +12,6 @@ import { RuntimeProviderService } from '../agent/runtime-provider-registry.servi
import { ChatGateway } from '../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../chat/embedded-chat.runtime.js';
import { HarnessChatRuntime } from '../chat/harness-chat.runtime.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import { DiscordReplayProtector } from './discord-replay-protector.js';
const SERVICE_TOKEN = 'test-service-token';
@@ -29,7 +25,6 @@ const ENV_KEYS = [
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CONFIG_ID',
'CHAT_HARNESS_RUNTIME',
] as const;
const savedEnv = new Map<string, string | undefined>();
@@ -155,57 +150,6 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
};
}
/**
* Task 5 fence (C): the Discord SEND path runs through the exclusive {@link ChatRuntimeRouter},
* constructed here in `pi-rpc` mode with a fully-resolved runtime (`active` = harness). A verified
* Discord *service* turn must nonetheless execute on the {@link EmbeddedChatRuntime} — never the
* harness, never the routing engine — per the Q1/Q2 adjudication: the router owns a dedicated
* verified-ingress dispatch that delegates to embedded regardless of mode, with zero harness
* fallback. The gateway is given the router in the former direct-`AgentService` constructor slot.
*
* RED today: production still reads that slot as a bare `AgentService`, so `this.agentService`
* resolves to the router, `getSession(...)` is not a function, the send path throws and is caught
* (an `error` is emitted and the handler returns) BEFORE it ever reaches the embedded runtime. The
* failure is behavioural wiring — collection, DI, and `onModuleInit` all succeed. GREEN re-routes
* the verified Discord dispatch through the router into the embedded runtime, satisfying the
* preserved create/prompt assertions without weakening any control. `harnessConversations.append`
* proves the harness path is never touched even though the pi-rpc router resolved it as `active`.
*
* Correction #4 is proved behaviourally, not by naming an accessor: the verified-ingress dispatch
* is reachable only from the fully-verified `discordService` branch (the create/prompt tests below)
* and never from a browser-emittable socket event (the browser-forgery refusal test).
*/
function readyPiRpcRegistry(): HarnessRegistry {
const registry = new HarnessRegistry();
// A registered 'pi' adapter + an available (non-sentinel) conversation service let the pi-rpc
// router resolve `active` = harness instead of failing closed at init, so these tests model the
// real hostile condition — the harness runtime IS live — rather than a degraded router.
registry.register({ id: 'pi' } as never);
return registry;
}
function piRpcRouterFronting(
agentService: unknown,
harnessConversations: { append: ReturnType<typeof vi.fn> },
): ChatRuntimeRouter {
const routerConversationServiceTripwire = {
append: () => {
throw new Error('router conversation service must not be resolved on the Discord path');
},
};
const embedded = new EmbeddedChatRuntime(agentService as never);
const harness = new HarnessChatRuntime(harnessConversations as never);
const router = new ChatRuntimeRouter(
readyPiRpcRegistry(),
routerConversationServiceTripwire as never,
embedded,
harness,
'pi-rpc',
);
router.onModuleInit();
return router;
}
describe('Discord ingress security', () => {
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
const [binding] = parseDiscordInteractionBindings(
@@ -489,7 +433,6 @@ describe('Discord ingress security', () => {
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
@@ -546,9 +489,8 @@ describe('Discord ingress security', () => {
},
};
const routingEngine = { resolve: vi.fn() };
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
agentService as never,
{} as never,
brain as never,
{} as never,
@@ -589,575 +531,6 @@ describe('Discord ingress security', () => {
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
);
expect(routingEngine.resolve).not.toHaveBeenCalled();
// Even though the pi-rpc router resolved the harness as `active`, verified Discord ingress must
// never touch it — the create path stays on the embedded runtime.
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('dispatches a verified Discord SEND once and drops a byte-identical replay with zero additional dispatch/persist/ack (Task 5 G4)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-replay',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
// One fully-valid signed envelope; the replay reuses the SAME object (same messageId).
const envelope = ingressEnvelope('verified once', 'discord-replay-001', {
conversationId: 'Nova:discord:channel-001',
});
// First delivery: the verified-Discord SEND runs the full embedded dispatch exactly once.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// Byte-identical replay: the messageId is already claimed, so resolveDiscordIngress returns
// null and the SEND handler bails before dispatch/persist/ack. Every effect stays at exactly one.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// The harness runtime is never touched on either delivery.
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND that fails the configured service identity consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once and a later duplicate stays fail-closed (Task 5 item 4 — claim ordering)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-claim-ordering',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
// A single fully-valid signed envelope, reused byte-for-byte across all three deliveries.
const envelope = ingressEnvelope('verified once with late identity', 'discord-order-001', {
conversationId: 'Nova:discord:channel-001',
});
// (1) Configured service identity is MISSING. The envelope is validly signed and passes the
// binding + route checks, but the SEND must refuse at the identity gate BEFORE any claim
// or effect. If the claim fires ahead of that gate, this delivery silently burns the
// replay claim for `discord-order-001` even though nothing dispatched.
delete process.env['DISCORD_SERVICE_USER_ID'];
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(0);
expect(prompt).toHaveBeenCalledTimes(0);
expect(addMessage).toHaveBeenCalledTimes(0);
expect(ackCount()).toBe(0);
// (2) Identity is now configured; the operator resends the SAME envelope byte-for-byte. Because
// step (1) consumed no claim, this corrected retry claims once and runs the full embedded
// dispatch exactly once. (Under the pre-fix ordering the claim was already spent in step (1),
// so this retry is dropped as a replay and never dispatches — the RED this test drives.)
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// (3) A genuine duplicate after a committed turn stays fail-closed: the claim taken in step (2)
// blocks it, so every effect remains at exactly one.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND whose configured agent record fails reconciliation consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
// The durable agent record does not reconcile on the first delivery (its name no longer matches
// the verified binding's instance id), then reconciles cleanly on the corrected retry.
const findAgent = vi
.fn()
.mockResolvedValueOnce({ id: 'agent-config-nova', name: 'Renamed-Away' })
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
const brain = {
agents: { findById: findAgent },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-reconcile',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
const envelope = ingressEnvelope(
'verified once with stale agent record',
'discord-reconcile-001',
{
conversationId: 'Nova:discord:channel-001',
},
);
// (1) The configured-agent reconcile runs BEFORE the replay claim. A mismatch refuses the turn
// and, crucially, consumes no claim for discord-reconcile-001 — nothing dispatches.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(0);
expect(prompt).toHaveBeenCalledTimes(0);
expect(addMessage).toHaveBeenCalledTimes(0);
expect(ackCount()).toBe(0);
// (2) The record now reconciles; because step (1) took no claim, this byte-identical retry claims
// once and runs the full embedded dispatch exactly once. (Pre-fix, the claim was spent ahead
// of the reconcile in step (1), so this retry was dropped as a replay — the RED this drives.)
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// (3) A genuine duplicate after the committed turn stays fail-closed.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND refuses to reuse a same-scope embedded session minted under a different configured identity, with zero prompt/persist/ack (Task 5 finding 3)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
// A live session already exists for this conversation/scope, but it was minted under a DIFFERENT
// configured agent (Orion). The verified binding reconciles to Nova, so reusing this session would
// execute one agent's turn under another agent's verified label — the reuse guard must refuse it.
const foreignIdentitySession = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-orion',
agentName: 'Orion',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const prompt = vi.fn().mockResolvedValue(undefined);
const createSession = vi.fn().mockResolvedValue(foreignIdentitySession);
const agentService = {
getSession: vi.fn().mockReturnValue(foreignIdentitySession),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-identity-swap',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('reuse under a different identity', 'discord-identity-swap-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
// Refused at the embedded reuse guard: no prompt, no persist, no ack — only a typed refusal.
expect(prompt).not.toHaveBeenCalled();
expect(addMessage).not.toHaveBeenCalled();
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(client.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
// The name matches the verified binding, but the record's own id is a DIFFERENT agent config —
// an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery,
// then admit the corrected record whose id matches the binding.
const findAgent = vi
.fn()
.mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' })
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
const brain = {
agents: { findById: findAgent },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-reconcile-id',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
const envelope = ingressEnvelope(
'verified once with aliased agent id',
'discord-reconcile-id-001',
{
conversationId: 'Nova:discord:channel-001',
},
);
// (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the
// turn BEFORE the replay claim, so nothing dispatches and the claim stays available.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(0);
expect(prompt).toHaveBeenCalledTimes(0);
expect(addMessage).toHaveBeenCalledTimes(0);
expect(ackCount()).toBe(0);
// (2) The record now reconciles on both id and name; because step (1) took no claim, this
// byte-identical retry claims once and runs the full embedded dispatch exactly once.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// (3) A genuine duplicate after the committed turn stays fail-closed.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
// No live session exists for this scope, so the runtime MINTS one — but createSession returns a
// session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova).
// The post-create identity recheck must refuse it rather than dispatch one agent's turn under
// another agent's verified label. (The existing reuse test covers the getSession path; this
// covers the createSession path scrappy flagged as unvalidated.)
const mintedForeignSession = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-orion',
agentName: 'Orion',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const prompt = vi.fn().mockResolvedValue(undefined);
const createSession = vi.fn().mockResolvedValue(mintedForeignSession);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-postcreate-mismatch',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('mint under a different identity', 'discord-postcreate-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
// The freshly minted session failed the post-create identity recheck: refused with a typed
// error, no prompt, no persist, no ack.
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).not.toHaveBeenCalled();
expect(addMessage).not.toHaveBeenCalled();
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(client.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('retains validated persisted attachments in resumed conversation history', async () => {
@@ -1220,16 +593,11 @@ describe('Discord ingress security', () => {
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const prompt = vi.fn().mockResolvedValue(undefined);
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const addMessage = vi.fn().mockResolvedValue(undefined);
const session = {
provider: 'test-provider',
modelId: 'test-model',
// The reused embedded session carries the SAME reconciled identity as the verified binding,
// so the finding-3 session-reuse guard admits it rather than refusing an identity swap.
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
@@ -1243,7 +611,6 @@ describe('Discord ingress security', () => {
prompt,
};
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
create: vi.fn().mockResolvedValue(undefined),
@@ -1251,9 +618,8 @@ describe('Discord ingress security', () => {
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
agentService as never,
{} as never,
brain as never,
{} as never,
@@ -1301,66 +667,6 @@ describe('Discord ingress security', () => {
}),
'discord-service',
);
// The verified Discord prompt dispatch stays on the embedded runtime; the pi-rpc harness that
// the router resolved as `active` is never reached.
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('refuses a browser-forged Discord ingress envelope in pi-rpc with a fixed typed refusal and zero dispatch', async () => {
// Correction #2 + #4 (behavioural). A browser socket is never `discordService` (that flag is
// set only on a valid service-token handshake), so it cannot forge the trusted Discord path by
// emitting an envelope-shaped payload. In pi-rpc it must receive a FIXED TYPED refusal
// (`runtime_unsupported`, the same typed code the sibling harness-fence uses) and reach neither
// the forced Discord service scope, the verified Discord operation, the embedded runtime, nor
// the harness. There is no dedicated socket event for verified ingress — the only ingress
// surface is the generic `message` handler, and a non-service client is refused there.
//
// RED today: a non-service client emitting an envelope-shaped payload falls to the browser
// branch, fails the chat-message shape check, and is dropped SILENTLY (a warn + return) with no
// typed refusal emitted — so the refusal assertion fails. Collection and construction succeed;
// the gap is behavioural. GREEN emits the fixed typed refusal before any dispatch.
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession: vi.fn(),
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
const harnessConversations = { append: vi.fn() };
const routingEngine = { resolve: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
{} as never,
{} as never,
routingEngine as never,
);
const client = {
id: 'browser-forging-discord',
data: { discordService: false },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('forged from a browser', 'browser-forgery-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
const refusal = client.emit.mock.calls.find(
([, payload]) => (payload as { code?: string } | undefined)?.code === 'runtime_unsupported',
);
expect(refusal).toBeDefined();
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(agentService.createSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(harnessConversations.append).not.toHaveBeenCalled();
expect(routingEngine.resolve).not.toHaveBeenCalled();
});
it('accepts a thread message through its allowed bound parent channel', () => {
-10
View File
@@ -10,16 +10,11 @@ import type {
AgentTextPayload,
AgentThinkingPayload,
ChatMessagePayload,
ChatSendCapabilityPayload,
ChatSendProtocol,
ClientToServerEvents,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
HarnessTurnSendPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
@@ -42,16 +37,11 @@ export type {
AgentTextPayload,
AgentThinkingPayload,
ChatMessagePayload,
ChatSendCapabilityPayload,
ChatSendProtocol,
ClientToServerEvents,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
HarnessTurnSendPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
+4 -11
View File
@@ -1,9 +1,8 @@
import { useState, type KeyboardEvent, type ReactElement } from 'react';
import type { HarnessSelection } from '@/lib/types';
import type { HarnessSelectionValue } from './use-harness-selection';
interface ComposerProps {
onSend: (input: { content: string; selection: HarnessSelection }) => boolean;
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
onStop: () => void;
streaming: boolean;
/** True from local send time through server turn startup/ack and
@@ -45,17 +44,11 @@ export function Composer({
if (busy) return;
// Send is gated on a validated, persisted catalog tuple — a draft or unset
// selection can never emit, so provider/model never travel as free text.
if (!harness.canSend || harness.persistedSelection === null) return;
if (!harness.canSend) return;
const trimmed = content.trim();
if (!trimmed) return;
// Pass the validated, persisted selection tuple only. The hook derives the
// wire projection (legacy `message` provider/model, or `turn:send`) from the
// negotiated `chat:send-capability` protocol — never from flat caller input.
const selection = harness.persistedSelection;
const ok = onSend({ content: trimmed, selection });
// Clear the input only when the send was accepted — a refused turn (e.g. a
// failed idempotency mint) must retain the user's text so it is not lost.
if (ok) setContent('');
onSend({ content: trimmed, ...harness.projection });
setContent('');
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
@@ -14,10 +14,6 @@ export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
export interface FakeChatSocket {
connected: boolean;
/** Mirrors socket.io-client's `Socket.id`: the connection identity the server
* echoes in a `chat:send-capability` payload. The generation-bound send
* protocol accepts an advertisement only when `payload.connectionId === id`. */
id: string;
connect(): FakeChatSocket;
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
@@ -55,10 +51,8 @@ export function createFakeChatSocket(): {
/** Simulates socket.io-client's automatic reconnect of the *same*
* instance after a transient disconnect: marks the socket connected again
* and fires any handler(s) registered via `socket.on('connect', ...)`,
* without clearing or replacing any listeners. A real reconnect is assigned
* a fresh `Socket.id`; pass `nextId` to model that new connection identity
* (defaults to the current id so existing callers are unaffected). */
simulateReconnect(nextId?: string): void;
* without clearing or replacing any listeners. */
simulateReconnect(): void;
} {
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
const emitted: EmittedEvent[] = [];
@@ -69,7 +63,6 @@ export function createFakeChatSocket(): {
// type-checked against ServerToClientEvents/ClientToServerEvents.
const socket = {
connected: false,
id: 'socket-a',
connect: vi.fn(function connect(this: void) {
socket.connected = true;
return socket;
@@ -112,9 +105,8 @@ export function createFakeChatSocket(): {
}
}
function simulateReconnect(nextId: string = socket.id): void {
function simulateReconnect(): void {
socket.connected = true;
socket.id = nextId;
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
for (const handler of listeners.get(lifecycleKey) ?? []) {
(handler as () => void)();
@@ -21,7 +21,6 @@ vi.mock('@/lib/socket', () => ({
destroySocket: destroySocketMock,
}));
import type { ChatSendProtocol, HarnessSelection } from '@mosaicstack/types';
import { useChatConnection, type ChatConnectionValue } from './use-chat-connection';
let fake: ReturnType<typeof createFakeChatSocket>;
@@ -34,126 +33,6 @@ function Harness(): null {
return null;
}
/**
* Task Five, Step Two (web send path) red-first support. These probe the FUTURE
* pi-rpc send contract against the CURRENT implementation, so the desired API is
* expressed here as a localized cast — production types stay untouched until Step
* Three. The reds fail on behaviour (legacy `message` emitted instead of
* `turn:send`; no nested selection; no idempotency key; void return; no
* conversation-id gating), never on a missing module or type.
*/
interface HarnessTurnSendInput {
readonly content: string;
readonly selection: HarnessSelection;
}
type HarnessSendMessage = (input: HarnessTurnSendInput) => boolean;
function harnessSend(): HarnessSendMessage {
return latest?.actions.sendMessage as unknown as HarnessSendMessage;
}
/**
* Task Five MAJOR-1 (browser send-protocol negotiation) support. The Gateway
* advertises how this connection may send via a server-to-client-only
* `chat:send-capability` (already part of the typed `ServerToClientEvents`
* contract, so this uses the fake's typed `serverEmit` — no cast); the hook
* holds the advertised protocol and routes `sendMessage` through an exhaustive
* switch on it, never inferring it from conversation/selection. When no listener
* is registered yet (CURRENT impl), the emit is an inert no-op, so the reds
* below fail on BEHAVIOUR — the current send path still infers a protocol and
* emits regardless of any advertisement — not on a missing module or type.
*/
function advertiseCapability(protocol: ChatSendProtocol, connectionId: string): void {
fake.serverEmit('chat:send-capability', { protocol, connectionId });
}
/**
* Install a controllable `crypto.randomUUID` on the global crypto object and
* return a restore fn. Uses defineProperty on the instance so it works whether
* or not the native method is configurable (it lives on the prototype, so an own
* property simply shadows it).
*/
function installRandomUUID(fn: () => string): () => void {
const g = globalThis as { crypto?: { randomUUID?: () => string } };
if (!g.crypto) {
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
}
const cryptoObj = g.crypto as { randomUUID?: () => string };
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
Object.defineProperty(cryptoObj, 'randomUUID', {
configurable: true,
writable: true,
value: fn,
});
return () => {
if (original) {
Object.defineProperty(cryptoObj, 'randomUUID', original);
} else {
Reflect.deleteProperty(cryptoObj, 'randomUUID');
}
};
}
/**
* Force `crypto.randomUUID` to read as ABSENT by shadowing it with an own
* `undefined` property. The native method lives on `Crypto.prototype`, so a
* bare delete of the (non-existent) own property would leave the inherited
* method visible — the shadow is what actually makes the call site see no
* secure generator. Returns a restore fn.
*/
function removeRandomUUID(): () => void {
const g = globalThis as { crypto?: { randomUUID?: () => string } };
if (!g.crypto) {
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
}
const cryptoObj = g.crypto as { randomUUID?: () => string };
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
Object.defineProperty(cryptoObj, 'randomUUID', {
configurable: true,
writable: true,
value: undefined,
});
return () => {
if (original) {
Object.defineProperty(cryptoObj, 'randomUUID', original);
} else {
Reflect.deleteProperty(cryptoObj, 'randomUUID');
}
};
}
/**
* Task Five, Step Two group 4/5 support — the FUTURE `turn:ack` receipt surface
* and the FUTURE fixed idempotency/rejection notice, expressed as a localized
* read-only view over `state`. Production `ChatConnectionState` gains
* `turnReceipt` at Step Three; the cast keeps production types untouched until
* then, so a success assertion against it fails on BEHAVIOUR (no turn:ack
* handler runs), never on a missing module. `error` already exists on state.
*/
interface HarnessTurnReceiptView {
readonly idempotencyKey: string;
readonly receiptId: string;
readonly selection: HarnessSelection;
}
interface HarnessTurnStateView {
readonly turnReceipt: HarnessTurnReceiptView | null | undefined;
readonly error: string | null;
}
function harnessTurnState(): HarnessTurnStateView {
return latest?.state as unknown as HarnessTurnStateView;
}
/**
* Emit a server `turn:ack` the CURRENT hook has no listener for — a safe no-op
* today (the fake iterates an empty handler set), so the group-4 reds fail
* because nothing is surfaced, not because this throws. The event name is cast
* past the compile-time `ServerToClientEvents` contract exactly as the
* `turn:send` client cast is; the typed event map lands at Step Three.
*/
function serverEmitTurnAck(payload: unknown): void {
fake.serverEmitRaw('turn:ack' as unknown as Parameters<typeof fake.serverEmitRaw>[0], payload);
}
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
@@ -188,20 +67,6 @@ afterEach(async () => {
});
describe('useChatConnection', () => {
// Task Five MAJOR-1: the send path is PROTOCOL-driven — `sendMessage` routes
// only on the negotiated `chat:send-capability`, never on inferred
// conversation/selection state. These pre-existing cases exercise the legacy
// `message` branch, so the connection is advertised `legacy-message` once here
// (server-to-client, for this exact socket id) after the mount registers its
// listener. Sub-describes that need the pi turn-runtime reset the generation
// and re-advertise `turn-send`; the capability describe resets to the
// unadvertised `unavailable` baseline and drives the protocol itself.
beforeEach(async () => {
await act(async () => {
advertiseCapability('legacy-message', fake.socket.id);
});
});
it('establishes the active conversation from the first message:ack when message omitted conversationId', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
@@ -479,10 +344,7 @@ describe('useChatConnection', () => {
it('sendMessage emits optional conversationId/provider/modelId and appends an optimistic user turn', async () => {
await act(async () => {
latest?.actions.sendMessage({
content: 'hello',
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
});
latest?.actions.sendMessage({ content: 'hello', provider: 'anthropic', modelId: 'claude' });
});
expect(fake.emitted).toContainEqual({
@@ -511,408 +373,6 @@ describe('useChatConnection', () => {
});
});
describe('turn:send harness routing (Task Five, Step Two red-first)', () => {
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
};
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
// The pi turn-runtime routes sends through `turn:send`. Reset the generation
// (clearing the outer `legacy-message` advertisement + first-wins lock) and
// advertise `turn-send` for this exact connection, so every send below takes
// the turn-runtime branch.
beforeEach(async () => {
await act(async () => {
fake.simulateReconnect();
});
await act(async () => {
advertiseCapability('turn-send', fake.socket.id);
});
});
async function establishConversation(): Promise<void> {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
}
it('emits a single turn:send with the nested selection tuple and a UUID idempotencyKey — never the legacy message event', async () => {
const restore = installRandomUUID(() => UUID);
try {
await establishConversation();
await act(async () => {
harnessSend()({ content: 'hello', selection });
});
} finally {
restore();
}
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
expect(sends).toHaveLength(1);
expect(sends[0]?.payload).toEqual({
conversationId: 'c1',
content: 'hello',
selection,
idempotencyKey: UUID,
});
// The pi-rpc sender must not fall back to the embedded `message` event.
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
});
it('generates the idempotencyKey with exactly one crypto.randomUUID() call per accepted send', async () => {
const gen = vi.fn(() => UUID);
const restore = installRandomUUID(gen);
try {
await establishConversation();
await act(async () => {
harnessSend()({ content: 'first', selection });
});
await act(async () => {
harnessSend()({ content: 'second', selection });
});
} finally {
restore();
}
expect(gen).toHaveBeenCalledTimes(2);
const keys = fake.emitted
.filter((e) => e.event === 'turn:send')
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
expect(keys).toEqual([UUID, UUID]);
});
it('does not send before an active conversation id exists (no first-send auto-create)', async () => {
const restore = installRandomUUID(() => UUID);
let returned: boolean | undefined;
try {
await act(async () => {
returned = harnessSend()({ content: 'too early', selection });
});
} finally {
restore();
}
expect(returned).toBe(false);
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
// Nothing optimistically appended when the send is refused.
expect(latest?.state.messages.some((m) => m.text === 'too early')).toBe(false);
});
it('returns true when it emits and false when the send is refused', async () => {
const restore = installRandomUUID(() => UUID);
let refusedEarly: boolean | undefined;
let acceptedAfter: boolean | undefined;
try {
await act(async () => {
refusedEarly = harnessSend()({ content: 'early', selection });
});
await establishConversation();
await act(async () => {
acceptedAfter = harnessSend()({ content: 'now', selection });
});
} finally {
restore();
}
expect(refusedEarly).toBe(false);
expect(acceptedAfter).toBe(true);
});
it('when secure UUID generation throws: emits nothing, appends nothing, releases the lock, and a later send succeeds', async () => {
await establishConversation();
const failing = installRandomUUID(() => {
throw new Error('secure random unavailable');
});
let firstReturn: boolean | undefined;
try {
await act(async () => {
firstReturn = harnessSend()({ content: 'blocked', selection });
});
} finally {
failing();
}
expect(firstReturn).toBe(false);
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
expect(latest?.state.messages.some((m) => m.text === 'blocked')).toBe(false);
// The send lock must have been released, so a subsequent valid send works.
const restore = installRandomUUID(() => UUID);
let secondReturn: boolean | undefined;
try {
await act(async () => {
secondReturn = harnessSend()({ content: 'retry', selection });
});
} finally {
restore();
}
expect(secondReturn).toBe(true);
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
});
});
describe('turn:ack receipt + rejection contract (Task Five, Step Two group 4)', () => {
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
};
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
// turn:ack is the receipt for a `turn:send`, so these establish under the pi
// turn-runtime: reset the generation (clearing the outer `legacy-message`
// advertisement + lock) and advertise `turn-send` for this connection.
beforeEach(async () => {
await act(async () => {
fake.simulateReconnect();
});
await act(async () => {
advertiseCapability('turn-send', fake.socket.id);
});
});
// Establish the conversation and send one accepted turn under a controlled
// idempotency key. Returns the crypto restore fn so callers unwind it.
async function establishAndSend(): Promise<() => void> {
const restore = installRandomUUID(() => UUID);
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
harnessSend()({ content: 'hello', selection });
});
return restore;
}
it('surfaces a turn:ack receipt echoing the exact idempotencyKey, server receiptId, and requested selection tuple', async () => {
const restore = await establishAndSend();
try {
await act(async () => {
serverEmitTurnAck({
conversationId: 'c1',
idempotencyKey: UUID,
receiptId: 'r1',
selection,
});
});
} finally {
restore();
}
// RED anchor: no turn:ack handler exists, so nothing is recorded. Green
// only when Step Three echoes the exact tuple back into state — never a
// substituted or fabricated one.
expect(harnessTurnState().turnReceipt).toEqual({
idempotencyKey: UUID,
receiptId: 'r1',
selection,
});
});
it('on a rejected turn:ack surfaces a visible safe notice, never the raw internal error, and fabricates no receipt tuple', async () => {
const restore = await establishAndSend();
try {
await act(async () => {
serverEmitTurnAck({
conversationId: 'c1',
idempotencyKey: UUID,
ok: false,
code: 'runtime_unsupported',
error: 'ADAPTER_BOOM internal stack: pi adapter unavailable at 0xdeadbeef',
});
});
} finally {
restore();
}
// RED anchor: a rejected ack must surface a visible notice; today no
// handler runs, so state.error stays null.
expect(harnessTurnState().error).toBeTruthy();
// The raw internal exception text must never reach the browser surface.
expect(harnessTurnState().error ?? '').not.toContain('ADAPTER_BOOM');
expect(harnessTurnState().error ?? '').not.toContain('0xdeadbeef');
// A rejection must not fabricate a success receipt tuple.
expect(harnessTurnState().turnReceipt ?? null).toBeNull();
});
it('uses one fixed safe rejection notice regardless of the internal cause (frozen union, not a passthrough)', async () => {
const firstRestore = await establishAndSend();
try {
await act(async () => {
serverEmitTurnAck({
conversationId: 'c1',
idempotencyKey: UUID,
ok: false,
code: 'runtime_unsupported',
error: 'cause-ALPHA adapter_unavailable',
});
});
} finally {
firstRestore();
}
const firstNotice = harnessTurnState().error;
// A fresh turn on the same conversation, rejected for a DIFFERENT internal
// reason, must surface the identical fixed notice.
const secondRestore = installRandomUUID(() => UUID);
try {
await act(async () => {
harnessSend()({ content: 'again', selection });
});
await act(async () => {
serverEmitTurnAck({
conversationId: 'c1',
idempotencyKey: UUID,
ok: false,
code: 'runtime_unsupported',
error: 'cause-BRAVO conversation_service_unavailable',
});
});
} finally {
secondRestore();
}
const secondNotice = harnessTurnState().error;
// RED anchor: both are null today; green requires a single frozen safe
// string surfaced for both distinct internal causes.
expect(firstNotice).toBeTruthy();
expect(secondNotice).toBeTruthy();
expect(firstNotice).toBe(secondNotice);
expect(firstNotice ?? '').not.toContain('ALPHA');
expect(secondNotice ?? '').not.toContain('BRAVO');
});
});
describe('idempotency-key failure semantics (Task Five, Step Two group 5)', () => {
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
};
const UUID_A = '11111111-1111-4111-8111-111111111111';
const UUID_B = '22222222-2222-4222-9222-222222222222';
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// The idempotency key is minted only on the pi turn-runtime `turn:send`
// branch: reset the generation (clearing the outer `legacy-message`
// advertisement + lock) and advertise `turn-send` for this connection.
beforeEach(async () => {
await act(async () => {
fake.simulateReconnect();
});
await act(async () => {
advertiseCapability('turn-send', fake.socket.id);
});
});
async function establish(): Promise<void> {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
}
it('mints a DISTINCT UUID-v4 idempotencyKey for each of two accepted turns — a key is never reused across turns', async () => {
const keys = [UUID_A, UUID_B];
let call = 0;
const restore = installRandomUUID(() => keys[call++] ?? UUID_A);
try {
await establish();
await act(async () => {
harnessSend()({ content: 'first', selection });
});
await act(async () => {
harnessSend()({ content: 'second', selection });
});
} finally {
restore();
}
const sent = fake.emitted
.filter((e) => e.event === 'turn:send')
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
// RED anchor: current sendMessage emits the legacy `message`, so no
// turn:send keys exist at all.
expect(sent).toHaveLength(2);
expect(sent[0]).toMatch(UUID_V4);
expect(sent[1]).toMatch(UUID_V4);
expect(sent[0]).not.toBe(sent[1]);
});
it('when crypto.randomUUID is ABSENT: surfaces a visible fixed idempotency-unavailable notice, emits nothing, appends nothing, releases the lock synchronously, and a later valid send succeeds', async () => {
await establish();
const restoreCrypto = removeRandomUUID();
let firstReturn: boolean | undefined;
try {
await act(async () => {
firstReturn = harnessSend()({ content: 'no-secure-random', selection });
});
} finally {
restoreCrypto();
}
// RED anchors: a refused send returns false and surfaces a visible notice.
expect(firstReturn).toBe(false);
expect(harnessTurnState().error).toBeTruthy();
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
expect(latest?.state.messages.some((m) => m.text === 'no-secure-random')).toBe(false);
// The lock released synchronously (no server event needed): a later valid
// send goes through.
const restore = installRandomUUID(() => UUID_A);
let secondReturn: boolean | undefined;
try {
await act(async () => {
secondReturn = harnessSend()({ content: 'recovered', selection });
});
} finally {
restore();
}
expect(secondReturn).toBe(true);
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
});
it('surfaces the SAME fixed idempotency-unavailable notice whether randomUUID is absent or throws, never leaking the thrown message', async () => {
// Case 1: absent.
await establish();
const restoreAbsent = removeRandomUUID();
try {
await act(async () => {
harnessSend()({ content: 'absent', selection });
});
} finally {
restoreAbsent();
}
const absentNotice = harnessTurnState().error;
// Case 2: throws with a distinctive internal message.
const failing = installRandomUUID(() => {
throw new Error('SECURE_RANDOM_BOOM entropy pool drained');
});
try {
await act(async () => {
harnessSend()({ content: 'throws', selection });
});
} finally {
failing();
}
const throwNotice = harnessTurnState().error;
// RED anchor: both are null today.
expect(absentNotice).toBeTruthy();
expect(throwNotice).toBeTruthy();
expect(absentNotice).toBe(throwNotice);
// The thrown internal detail must never reach the browser surface.
expect(throwNotice ?? '').not.toContain('SECURE_RANDOM_BOOM');
expect(throwNotice ?? '').not.toContain('entropy pool');
});
});
it('abort emits abort with the active conversationId', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
@@ -1224,16 +684,7 @@ describe('useChatConnection', () => {
expect(latest?.state.approvalRequestPending).toBe(false);
// The send lock must also be released — a subsequent sendMessage after
// reconnect must not be permanently blocked by the interrupted turn. The
// disconnect also voids the negotiated send protocol (MAJOR-1), so model the
// reconnect handshake — the socket reconnects and the server re-advertises
// how this connection may send — before probing the released lock.
await act(async () => {
fake.simulateReconnect();
});
await act(async () => {
advertiseCapability('legacy-message', fake.socket.id);
});
// reconnect must not be permanently blocked by the interrupted turn.
await act(async () => {
latest?.actions.sendMessage({ content: 'after reconnect' });
});
@@ -2214,319 +1665,4 @@ describe('useChatConnection', () => {
}
expect(destroySocketMock).toHaveBeenCalledOnce();
});
describe('chat:send-capability protocol negotiation (Task Five MAJOR-1, red-first)', () => {
const capSelection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
};
const UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
// The one fixed, safe user-facing notice the hook must surface (code
// `send_protocol_unavailable`) when a send is attempted on a connection whose
// advertised protocol is `unavailable`/unknown/absent. Contract-frozen string.
const UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.';
// These tests each drive the protocol negotiation themselves, so they must
// start from a clean, unadvertised generation. Reconnect resets protocolRef
// to `unavailable` and clears the outer `legacy-message` first-wins lock
// WITHOUT advertising — no client emit, so `fake.emitted` stays empty and the
// "starts unavailable" premise holds.
beforeEach(async () => {
await act(async () => {
fake.simulateReconnect();
});
});
async function establishConversation(): Promise<void> {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
}
function connectCalls(): number {
return (fake.socket.connect as unknown as { mock: { calls: unknown[] } }).mock.calls.length;
}
it('starts with no advertised protocol: a send is refused, emits nothing, mints no key, and surfaces the fixed unavailable notice', async () => {
// No `chat:send-capability` has arrived, so the connection has not been told
// it may send at all. The current impl infers "selection + no conversation +
// no flat provider/model → return false" but SURFACES NOTHING — the red is
// that the fixed `send_protocol_unavailable` notice is never set.
let uuidCalls = 0;
const restore = installRandomUUID(() => {
uuidCalls += 1;
return UUID;
});
let returned: boolean | undefined;
try {
await act(async () => {
returned = harnessSend()({ content: 'hi', selection: capSelection });
});
} finally {
restore();
}
expect(returned).toBe(false);
expect(fake.emitted).toHaveLength(0);
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
// The test's name promises "mints no key": the unavailable branch must not
// reach the idempotency mint at all. Without this assertion a defect that
// mints a key before refusing survives.
expect(uuidCalls).toBe(0);
// ...and no user content may be optimistically appended on refusal.
expect(latest?.state.messages.some((m) => m.text === 'hi')).toBe(false);
});
it('legacy-message advertised overrides conversation-inference: an established conversation still routes the legacy message event, never turn:send', async () => {
// Same inputs the inference impl routes to `turn:send` (selection + active
// conversation). The advertised protocol is authoritative: it must emit the
// legacy `message` event instead. Red: current impl emits turn:send.
const restore = installRandomUUID(() => UUID);
try {
await establishConversation();
await act(async () => {
advertiseCapability('legacy-message', fake.socket.id);
});
await act(async () => {
harnessSend()({ content: 'hi', selection: capSelection });
});
} finally {
restore();
}
expect(fake.emitted.filter((e) => e.event === 'turn:send')).toHaveLength(0);
expect(fake.emitted).toContainEqual({
event: 'message',
payload: { conversationId: 'c1', content: 'hi', provider: 'anthropic', modelId: 'claude' },
});
});
it('legacy-message advertised with no conversation: derives provider/model from the selection tuple and emits one message', async () => {
// The flat provider/modelId caller inputs are gone; the legacy branch must
// source them from the confirmed persisted selection. Red: current impl
// refuses a bare harness send (selection + no flat fields → return false).
let returned: boolean | undefined;
const restore = installRandomUUID(() => UUID);
try {
await act(async () => {
advertiseCapability('legacy-message', fake.socket.id);
});
await act(async () => {
returned = harnessSend()({ content: 'first', selection: capSelection });
});
} finally {
restore();
}
expect(returned).toBe(true);
expect(fake.emitted).toContainEqual({
event: 'message',
payload: {
conversationId: undefined,
content: 'first',
provider: 'anthropic',
modelId: 'claude',
},
});
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
});
it('unavailable advertised: refuses even with an active conversation and selection, emits nothing, surfaces the fixed notice', async () => {
// Red: current impl ignores the advertisement and emits turn:send.
let uuidCalls = 0;
const restore = installRandomUUID(() => {
uuidCalls += 1;
return UUID;
});
let returned: boolean | undefined;
try {
await establishConversation();
await act(async () => {
advertiseCapability('unavailable', fake.socket.id);
});
await act(async () => {
returned = harnessSend()({ content: 'nope', selection: capSelection });
});
} finally {
restore();
}
expect(returned).toBe(false);
expect(fake.emitted).toHaveLength(0);
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
// Refusal must not optimistically append the user's turn to the transcript
// (a distinct leak from the emit): the unavailable branch appends nothing.
expect(latest?.state.messages.some((m) => m.text === 'nope')).toBe(false);
// ...and must not mint an idempotency key on the refused path.
expect(uuidCalls).toBe(0);
});
it('ignores an advertisement whose connectionId does not match the socket id: protocol stays unavailable and the send is refused', async () => {
// A capability minted for a different (stale/foreign) connection must never
// arm this one. Red: current impl has no connection-id gate and emits
// turn:send off the inferred path.
const restore = installRandomUUID(() => UUID);
let returned: boolean | undefined;
try {
await establishConversation();
await act(async () => {
advertiseCapability('legacy-message', 'a-different-connection');
});
await act(async () => {
returned = harnessSend()({ content: 'spoof', selection: capSelection });
});
} finally {
restore();
}
expect(returned).toBe(false);
expect(fake.emitted).toHaveLength(0);
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
});
it('accepts only the first advertisement for the generation: a later conflicting protocol is ignored', async () => {
// legacy-message wins; the subsequent turn-send is a replay/conflict and is
// dropped. Red: current impl ignores both and infers turn:send.
const restore = installRandomUUID(() => UUID);
try {
await establishConversation();
await act(async () => {
advertiseCapability('legacy-message', fake.socket.id);
});
await act(async () => {
advertiseCapability('turn-send', fake.socket.id);
});
await act(async () => {
harnessSend()({ content: 'hi', selection: capSelection });
});
} finally {
restore();
}
expect(fake.emitted.filter((e) => e.event === 'turn:send')).toHaveLength(0);
expect(fake.emitted).toContainEqual({
event: 'message',
payload: { conversationId: 'c1', content: 'hi', provider: 'anthropic', modelId: 'claude' },
});
});
it('resets to unavailable on disconnect: a later send is refused and never reconnects the socket', async () => {
// Disconnect voids the advertised protocol for the generation. The send must
// refuse and MUST NOT call socket.connect() to force a reconnection. Red:
// current impl keeps the conversation, infers turn:send, and its turn:send
// branch calls socket.connect() when the socket is disconnected.
const restore = installRandomUUID(() => UUID);
let returned: boolean | undefined;
let connectsDuringSend = 0;
try {
await establishConversation();
await act(async () => {
advertiseCapability('legacy-message', fake.socket.id);
});
await act(async () => {
fake.simulateDisconnect();
});
const before = connectCalls();
await act(async () => {
returned = harnessSend()({ content: 'after-drop', selection: capSelection });
});
connectsDuringSend = connectCalls() - before;
} finally {
restore();
}
expect(returned).toBe(false);
expect(fake.emitted).toHaveLength(0);
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
expect(connectsDuringSend).toBe(0);
});
it('resets on reconnect to a fresh generation: refuses until re-advertised, then honors the new advertisement', async () => {
// A reconnect mints a new Socket.id and a new generation; the prior
// advertisement (bound to the old id) is stale and must not carry over. The
// hook only trusts a fresh advertisement for the new connection. Red:
// current impl has no connect listener and keeps inferring turn:send.
const restore = installRandomUUID(() => UUID);
let refusedAfterReconnect: boolean | undefined;
try {
await establishConversation();
await act(async () => {
advertiseCapability('legacy-message', fake.socket.id);
});
await act(async () => {
fake.simulateReconnect('socket-b');
});
await act(async () => {
refusedAfterReconnect = harnessSend()({ content: 'stale', selection: capSelection });
});
} finally {
restore();
}
expect(refusedAfterReconnect).toBe(false);
expect(fake.emitted).toHaveLength(0);
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
// A fresh advertisement for the reconnected id (socket-b) re-arms sending.
const restore2 = installRandomUUID(() => UUID);
try {
await act(async () => {
advertiseCapability('legacy-message', 'socket-b');
});
await act(async () => {
harnessSend()({ content: 'welcome-back', selection: capSelection });
});
} finally {
restore2();
}
expect(fake.emitted).toContainEqual({
event: 'message',
payload: {
conversationId: 'c1',
content: 'welcome-back',
provider: 'anthropic',
modelId: 'claude',
},
});
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
});
it('routes on the synchronous protocol ref, not the batched reducer mirror: an advertisement and a send in the SAME tick still route by the just-advertised protocol', async () => {
// An advertisement lands and a send is issued within one synchronous tick,
// before React commits the reducer's `sendProtocol` mirror. The send is
// captured from the pre-advertisement render, so its closed-over reducer
// state still reads `sendProtocol === 'unavailable'`; the capability
// handler, however, has already set the synchronous `protocolRef` to
// `legacy-message`. The hook must route on that ref. Red (against a
// stale-mirror routing that reads `state.sendProtocol`): the send reads the
// pre-advertisement `unavailable` and refuses instead of emitting `message`.
const restore = installRandomUUID(() => UUID);
try {
await act(async () => {
// Bound to the CURRENT (pre-advertisement) render — its closure still
// sees the reset `unavailable` mirror even after the advert dispatches.
const sendBeforeCommit = harnessSend();
advertiseCapability('legacy-message', fake.socket.id);
// Same tick, no await: React has not committed the new mirror yet, so
// only `protocolRef` reflects `legacy-message`.
sendBeforeCommit({ content: 'same-tick', selection: capSelection });
});
} finally {
restore();
}
expect(fake.emitted).toContainEqual({
event: 'message',
payload: {
conversationId: undefined,
content: 'same-tick',
provider: 'anthropic',
modelId: 'claude',
},
});
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
});
});
});
+13 -250
View File
@@ -12,7 +12,6 @@ import {
import {
asConversationId,
asFiniteNumber,
asHarnessSelection,
asString,
asStringArray,
isRecord,
@@ -22,14 +21,10 @@ import type {
AgentStartPayload,
AgentTextPayload,
AgentThinkingPayload,
ChatSendCapabilityPayload,
ChatSendProtocol,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
MessageAckPayload,
SessionInfoPayload,
SessionUsagePayload,
@@ -135,42 +130,6 @@ const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please tr
* dropped. */
const APPROVAL_LIMIT_MESSAGE = 'Approval limit reached for this session. This command was not run.';
/** Fixed, browser-safe notice surfaced when the harness runtime rejects a turn
* (`turn:ack` with `ok:false`). It is deliberately generic: the raw server
* `code`/`message`/`error` can carry adapter internals or entropy-source detail,
* so no rejection ever leaks its cause into the UI every distinct rejection
* shows this same string. */
const TURN_REJECTED_NOTICE = 'This turn could not be sent. Please try again.';
/** Fixed, browser-safe notice surfaced when a turn is refused because the
* idempotency-key mint failed closed (`crypto.randomUUID` absent or throwing).
* Like {@link TURN_REJECTED_NOTICE}, it never carries the thrown message. */
const IDEMPOTENCY_UNAVAILABLE_NOTICE = 'This turn could not be sent. Please try again.';
/** The single fixed, browser-safe notice surfaced (with safe code
* `send_protocol_unavailable`) when a send is attempted on a connection whose
* negotiated send protocol is `unavailable` the server never advertised a
* usable `chat:send-capability`, advertised `unavailable` (e.g. a pi-rpc runtime
* in this slice), or the advertisement was rejected (wrong connection id, replay,
* or an unknown protocol). It carries no dynamic detail. */
const SEND_PROTOCOL_UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.';
/** Mints a single idempotency key for one accepted `turn:send`, fail-closed.
* Returns a fresh RFC-4122 UUID from `crypto.randomUUID`, or `null` when that
* source is absent (not a function) or throws the caller then refuses the turn
* rather than falling back to any non-cryptographic source (Math.random, a
* clock, or a counter would all be forgeable/collision-prone). Never throws. */
function mintIdempotencyKey(): string | null {
try {
const c: unknown = globalThis.crypto;
if (!isRecord(c) || typeof c.randomUUID !== 'function') return null;
const key = (c.randomUUID as () => unknown)();
return typeof key === 'string' && key.length > 0 ? key : null;
} catch {
return null;
}
}
/** True only for the narrow case a malformed-conversationId `error`/`agent:end`
* must be treated as a terminal startup failure: no conversation has ever been
* established yet, and a send is still pending one. Once a conversation is
@@ -277,14 +236,6 @@ export interface PendingApproval {
args?: string;
}
/** Receipt captured from an accepted harness `turn:ack` the minimal record proving the
* server accepted this exact turn under its minted idempotency key and selection tuple. */
export interface HarnessTurnReceipt {
idempotencyKey: string;
receiptId: string;
selection: HarnessSelection;
}
export interface ChatConnectionState {
conversationId: string | null;
/** True once a message has been sent while no conversation is active yet, so the
@@ -317,18 +268,6 @@ export interface ChatConnectionState {
approvalRequestPending: boolean;
systemReload: SystemReloadPayload | null;
error: string | null;
/** How this connection is currently permitted to send, negotiated via the
* server-to-client-only `chat:send-capability` advertisement. Starts and resets
* to `'unavailable'` on every (re)connect and disconnect a fresh or dropped
* connection has no usable protocol until the server (re-)advertises. This is
* the reactive/UI mirror of the synchronous `protocolRef` that `sendMessage`
* actually reads; the ref is authoritative because an advertisement and a send
* can occur in the same tick before React re-renders. */
sendProtocol: ChatSendProtocol;
/** Receipt from the most recently accepted harness `turn:ack`, or null before any
* turn has been accepted. A rejected turn:ack surfaces via `error` and leaves this
* untouched (a prior accepted receipt is not erased by a later rejection). */
turnReceipt: HarnessTurnReceipt | null;
messages: ChatTranscriptMessage[];
/** Monotonically increasing counter used to mint transcript message ids
* never reset while retained messages remain, so ids stay unique across the
@@ -369,7 +308,7 @@ export interface ChatConnectionState {
}
export interface ChatConnectionActions {
sendMessage: (input: { content: string; selection?: HarnessSelection }) => boolean;
sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void;
abort: () => void;
setThinking: (level: string) => void;
executeCommand: (input: { command: string; args?: string }) => void;
@@ -402,8 +341,6 @@ const initialState: ChatConnectionState = {
approvalRequestPending: false,
systemReload: null,
error: null,
sendProtocol: 'unavailable',
turnReceipt: null,
messages: [],
messageSeq: 0,
toolSeq: 0,
@@ -424,12 +361,7 @@ type Action =
| { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload }
| { type: 'server/system:reload'; payload: SystemReloadPayload }
| { type: 'server/error'; payload: ErrorPayload }
| { type: 'server/turn:ack'; payload: HarnessTurnAckPayload }
| { type: 'local/send'; content: string }
| { type: 'local/capability'; protocol: ChatSendProtocol }
| { type: 'local/reset-protocol' }
| { type: 'local/send-unavailable' }
| { type: 'local/turn-idempotency-unavailable' }
| { type: 'local/approve-request'; command: string; args?: string }
| { type: 'local/consume-approval' }
| { type: 'local/approval-saturated' }
@@ -846,30 +778,6 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
};
}
case 'server/turn:ack': {
// The harness runtime's turn acknowledgement. The success shape carries a
// receipt id + minted idempotencyKey + echoed selection; the failure shape
// is discriminated on `ok === false`. Every field is runtime-untrusted (the
// top-of-reducer guard already rejected a non-object payload).
const record = action.payload as Record<string, unknown>;
if (record.ok === false) {
// A rejected turn surfaces a FIXED browser-safe notice — never the raw
// server `message`/`error`/`code`, which can carry adapter internals — and
// does not disturb any previously accepted receipt.
return { ...state, error: TURN_REJECTED_NOTICE };
}
const idempotencyKey = asString(record.idempotencyKey);
// The web ack uses `receiptId`; fall back to the frozen contract's `turnId`.
const receiptId = asString(record.receiptId) || asString(record.turnId);
const selection = asHarnessSelection(record.selection);
if (idempotencyKey.length === 0 || receiptId.length === 0 || selection === null) {
// A malformed success frame is ignored outright rather than recorded as a
// half-populated receipt.
return state;
}
return { ...state, turnReceipt: { idempotencyKey, receiptId, selection } };
}
case 'local/send': {
const message: ChatTranscriptMessage = {
// Sourced from the reducer-owned `messageSeq` counter — see the
@@ -894,39 +802,6 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
};
}
case 'local/capability': {
// The FIRST valid `chat:send-capability` for this connection generation has
// been accepted (connection-id gating + first-wins enforced in the handler);
// record how this connection may now send. This is the reactive mirror of
// the synchronous `protocolRef` the send path reads.
return { ...state, sendProtocol: action.protocol };
}
case 'local/reset-protocol': {
// A (re)connect or disconnect voids any negotiated protocol: a fresh or
// dropped connection has no usable send capability until the server
// (re-)advertises. Reset to `unavailable` so no stale advertisement can
// authorize a send across a connection boundary.
if (state.sendProtocol === 'unavailable') return state;
return { ...state, sendProtocol: 'unavailable' };
}
case 'local/send-unavailable': {
// A send was attempted while the negotiated protocol is `unavailable`
// (never advertised / advertised unavailable / rejected advertisement).
// Surface the single FIXED safe notice — nothing was emitted, minted,
// appended, or locked.
return { ...state, error: SEND_PROTOCOL_UNAVAILABLE_NOTICE };
}
case 'local/turn-idempotency-unavailable': {
// The idempotency-key mint failed closed (crypto.randomUUID absent or
// throwing), so the turn was refused before emit. Surface a FIXED notice —
// never the underlying thrown message, which can leak entropy-source
// internals.
return { ...state, error: IDEMPOTENCY_UNAVAILABLE_NOTICE };
}
case 'local/disconnect': {
// A transient socket disconnect must not leave the UI stuck waiting on
// a turn/approval/send that will never resolve on this connection.
@@ -1007,20 +882,6 @@ export function useChatConnection(): ChatConnectionValue {
approveLockRef.current = state.approvalRequestPending;
}, [state.approvalRequestPending]);
// Synchronous, generation-bound send protocol. `state.sendProtocol` drives the
// reactive UI, but reducer updates are batched/async — a `chat:send-capability`
// advertisement and a `sendMessage` can land in the same tick before React
// re-renders — so this ref is the source of truth the send path reads. Unlike
// sendLockRef/approveLockRef (synchronized FROM the reducer), this ref is
// written directly by the socket lifecycle/capability handlers below, which
// also dispatch the reducer mirror. It is NOT synchronized from state, because
// its whole purpose is to be correct BEFORE the reducer has re-rendered.
const protocolRef = useRef<ChatSendProtocol>('unavailable');
// True once the first valid advertisement for the CURRENT connection generation
// has been accepted; every later advertisement (a conflicting or replayed one)
// is ignored until the next (re)connect/disconnect resets the generation.
const protocolLockedRef = useRef(false);
useEffect(() => {
const socket = getSocket();
@@ -1052,45 +913,7 @@ export function useChatConnection(): ChatConnectionValue {
const onError = (payload: ErrorPayload): void => {
dispatch({ type: 'server/error', payload });
};
const onTurnAck = (payload: HarnessTurnAckPayload): void =>
dispatch({ type: 'server/turn:ack', payload });
// Void the negotiated send protocol at every connection-lifecycle boundary.
// A fresh or dropped connection has no usable capability until the server
// (re-)advertises, so no advertisement bound to a prior connection may carry
// across the boundary and authorize a send. Both write the synchronous ref
// AND unlock first-wins, then dispatch the reducer mirror.
const resetSendProtocol = (): void => {
protocolRef.current = 'unavailable';
protocolLockedRef.current = false;
dispatch({ type: 'local/reset-protocol' });
};
const onConnect = (): void => {
resetSendProtocol();
};
const onCapability = (payload: ChatSendCapabilityPayload): void => {
// Server-to-client-only advertisement of how THIS connection may send.
// Accept only the FIRST valid one per generation, and only when it names
// this exact connection (`connectionId === socket.id`): a capability minted
// for another or stale connection must never arm this one. The payload is
// runtime-untrusted despite its compile-time type, so every field is
// guard-checked and an unknown protocol is dropped (leaving `unavailable`).
if (protocolLockedRef.current) return;
if (!isRecord(payload)) return;
const { protocol, connectionId } = payload as {
protocol?: unknown;
connectionId?: unknown;
};
if (typeof connectionId !== 'string' || connectionId !== socket.id) return;
if (protocol !== 'legacy-message' && protocol !== 'turn-send' && protocol !== 'unavailable') {
return;
}
protocolLockedRef.current = true;
protocolRef.current = protocol;
dispatch({ type: 'local/capability', protocol });
};
const onDisconnect = (): void => {
resetSendProtocol();
dispatch({ type: 'local/disconnect' });
};
@@ -1107,11 +930,6 @@ export function useChatConnection(): ChatConnectionValue {
socket.on('command:approval', onCommandApproval);
socket.on('system:reload', onSystemReload);
socket.on('error', onError);
socket.on('turn:ack', onTurnAck);
// Registered BEFORE connect so the initial post-auth advertisement (and any
// reconnect) can never race ahead of its listener.
socket.on('connect', onConnect);
socket.on('chat:send-capability', onCapability);
socket.on('disconnect', onDisconnect);
if (!socket.connected) {
@@ -1132,79 +950,24 @@ export function useChatConnection(): ChatConnectionValue {
socket.off('command:approval', onCommandApproval);
socket.off('system:reload', onSystemReload);
socket.off('error', onError);
socket.off('turn:ack', onTurnAck);
socket.off('connect', onConnect);
socket.off('chat:send-capability', onCapability);
socket.off('disconnect', onDisconnect);
destroySocket();
};
}, []);
const actions: ChatConnectionActions = {
sendMessage: ({ content, selection }) => {
// Routing is PROTOCOL-driven, never inferred from conversation/selection/
// provider/local mode: the server advertised, once per connection, exactly
// how this connection may send, and that advertisement is authoritative.
// The exhaustive switch maps each protocol to its ONE event; the send path
// never reconnects the socket (a dropped connection has already reset the
// protocol to `unavailable`, so no emit branch is reachable while offline).
switch (protocolRef.current) {
case 'legacy-message': {
// Embedded/legacy runtime: EVERY browser turn — the first (which
// creates the conversation) and every later one — is the `message`
// event. provider/model are sourced ONLY from the confirmed persisted
// selection tuple, never from separate flat caller inputs.
if (sendLockRef.current || state.streaming || state.sending) return false;
sendLockRef.current = true;
const socket = getSocket();
dispatch({ type: 'local/send', content });
socket.emit('message', {
conversationId: state.conversationId ?? undefined,
content,
provider: selection?.providerId,
modelId: selection?.modelId,
});
return true;
}
case 'turn-send': {
// Pi turn-runtime: the exclusive `turn:send` contract. Requires an
// already-established conversation AND a confirmed persisted selection
// tuple; it is lock-independent (no send lock, no optimistic append),
// and mints exactly one idempotency key per accepted turn, failing the
// turn closed if the mint fails. A premature send (no conversation yet,
// or no selection) is refused with no emit and no notice.
if (selection == null || state.conversationId === null) return false;
const idempotencyKey = mintIdempotencyKey();
if (idempotencyKey === null) {
dispatch({ type: 'local/turn-idempotency-unavailable' });
return false;
}
const socket = getSocket();
socket.emit('turn:send', {
conversationId: state.conversationId,
content,
selection,
idempotencyKey,
});
return true;
}
case 'unavailable': {
// No usable protocol negotiated for this connection: refuse without
// emitting, minting, appending, or acquiring the lock, and surface the
// one fixed safe notice (code `send_protocol_unavailable`).
dispatch({ type: 'local/send-unavailable' });
return false;
}
default: {
// Exhaustiveness guard: every ChatSendProtocol member is handled above.
// An unknown value can never arm a send — refuse exactly as
// `unavailable` rather than falling through to any emit.
const _exhaustive: never = protocolRef.current;
void _exhaustive;
dispatch({ type: 'local/send-unavailable' });
return false;
}
}
sendMessage: ({ content, provider, modelId }) => {
if (sendLockRef.current || state.streaming || state.sending) return;
sendLockRef.current = true;
const socket = getSocket();
if (!socket.connected) socket.connect();
dispatch({ type: 'local/send', content });
socket.emit('message', {
conversationId: state.conversationId ?? undefined,
content,
provider,
modelId,
});
},
abort: () => {
@@ -226,10 +226,7 @@ describe('useHarnessSelection', () => {
modelId: 'gpt-5',
});
expect(value().canSend).toBe(true);
// Task Five: the composer sends the nested `persistedSelection` tuple directly.
// The Task-Four compat flat `projection` ({provider, modelId}) is removed — the
// harnessId must never be dropped on the way to the wire.
expect('projection' in value()).toBe(false);
expect(value().projection).toEqual({ provider: 'openai', modelId: 'gpt-5' });
});
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
@@ -389,8 +386,7 @@ describe('useHarnessSelection', () => {
providerId: 'anthropic',
modelId: 'claude',
});
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
expect('projection' in value()).toBe(false);
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
});
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
@@ -424,8 +420,7 @@ describe('useHarnessSelection', () => {
});
await flush();
expect(value().canSend).toBe(true);
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
expect('projection' in value()).toBe(false);
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
});
it('never requests any /api/providers* endpoint across the whole flow', async () => {
@@ -42,6 +42,10 @@ export interface HarnessSelectionValue {
* resolves the composite option identity to the real entry and passes both
* ids, so a bare model id is never combined with ambient provider state. */
selectModel: (providerId: string, modelId: string) => void;
/** The compatibility `{provider, modelId}` projection for the legacy socket
* send path derived ONLY from the validated persisted tuple, never from any
* free-text or unpersisted draft. Empty when nothing is sendable. */
projection: { provider?: string; modelId?: string };
}
/** A tuple is a currently-usable catalog option only when the catalog holds a
@@ -189,6 +193,9 @@ export function useHarnessSelection(): HarnessSelectionValue {
!catalogUnavailable &&
tuplesEqual(draft, persistedSelection) &&
isAvailableInCatalog(persistedSelection, catalog);
const projection: { provider?: string; modelId?: string } = canSend
? { provider: persistedSelection.providerId, modelId: persistedSelection.modelId }
: {};
return {
harnesses,
@@ -204,5 +211,6 @@ export function useHarnessSelection(): HarnessSelectionValue {
selectHarness,
selectProvider,
selectModel,
projection,
};
}
-213
View File
@@ -108,58 +108,6 @@ async function flushAsync(times = 5): Promise<void> {
}
}
/** Deterministic idempotency key for the Task Five red-first page send test. */
const PAGE_UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
/** Install a controllable `crypto.randomUUID` and return a restore fn. Uses
* defineProperty on the crypto instance so it works whether or not the native
* method is configurable (it lives on the prototype; an own property shadows it). */
function installRandomUUID(fn: () => string): () => void {
const g = globalThis as { crypto?: { randomUUID?: () => string } };
if (!g.crypto) {
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
}
const cryptoObj = g.crypto as { randomUUID?: () => string };
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
Object.defineProperty(cryptoObj, 'randomUUID', {
configurable: true,
writable: true,
value: fn,
});
return () => {
if (original) {
Object.defineProperty(cryptoObj, 'randomUUID', original);
} else {
Reflect.deleteProperty(cryptoObj, 'randomUUID');
}
};
}
/**
* Task Five MAJOR-1: the send path is PROTOCOL-driven the browser may send only
* as the server advertised, once per connection, over the server-to-client-only
* `chat:send-capability`. Model that advertisement for THIS connection id so the
* page send tests take the intended branch. `legacy-message` is the default
* (advertised in `beforeEach`/`remountWithFetch`); the pi turn-runtime tests
* reset the generation and re-advertise `turn-send` via the helper below.
*/
function advertiseSendCapability(protocol: 'legacy-message' | 'turn-send' | 'unavailable'): void {
fake.serverEmit('chat:send-capability', { protocol, connectionId: fake.socket.id });
}
/** Reset the negotiated protocol to a fresh, unlocked generation (clearing the
* default `legacy-message` advertisement + first-wins lock), then advertise the
* pi turn-runtime `turn:send` protocol for this connection. The per-test override
* for the page send tests that route through `turn:send`. */
async function advertiseTurnSendGeneration(): Promise<void> {
await act(async () => {
fake.simulateReconnect();
});
await act(async () => {
advertiseSendCapability('turn-send');
});
}
let fake: ReturnType<typeof createFakeChatSocket>;
let root: Root | null;
let container: HTMLElement;
@@ -189,12 +137,6 @@ beforeEach(async () => {
// Settle the selection hook's mount fetches so the default in-catalog tuple
// persists and `canSend` is true for the existing send-path tests.
await flushAsync();
// Model the server's post-auth send-capability advertisement (MAJOR-1). Most
// page send tests exercise the legacy `message` branch; the pi turn-runtime
// tests override to `turn-send` via advertiseTurnSendGeneration().
await act(async () => {
advertiseSendCapability('legacy-message');
});
});
afterEach(async () => {
@@ -217,11 +159,6 @@ async function remountWithFetch(fetchImpl: typeof fetch): Promise<void> {
root?.render(<ChatPage />);
});
await flushAsync();
// Re-advertise on the remounted connection — the prior generation's capability
// does not carry across a remount (fresh hook instance, unadvertised protocol).
await act(async () => {
advertiseSendCapability('legacy-message');
});
}
describe('ChatPage', () => {
@@ -634,156 +571,6 @@ describe('ChatPage', () => {
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
});
it('emits turn:send with the nested persisted selection tuple and a UUID idempotency key (never the legacy message event)', async () => {
await advertiseTurnSendGeneration();
const restore = installRandomUUID(() => PAGE_UUID);
try {
// Send is disabled without an active conversation — establish one first.
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'hello there');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
} finally {
restore();
}
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
expect(sends).toHaveLength(1);
expect(sends[0]?.payload).toEqual({
conversationId: 'c1',
content: 'hello there',
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
idempotencyKey: PAGE_UUID,
});
// The pi-rpc page send must not emit the embedded `message` event, and must
// never send a flat {provider, modelId} that drops the harnessId.
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
});
it('keeps the composer content and emits nothing when the send cannot mint an idempotency key, so the user can retry (composer clears only on success) — Task Five group 5', async () => {
await advertiseTurnSendGeneration();
const failing = installRandomUUID(() => {
throw new Error('secure random unavailable');
});
try {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'keep me');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
// No wire traffic: neither the harness turn nor the legacy message.
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
// The composer retained its content — it clears ONLY on a successful send,
// so the user can retry without retyping.
expect(textarea.value).toBe('keep me');
// A visible, safe notice explains why nothing was sent.
expect(container.querySelector('[role="alert"]')).toBeTruthy();
} finally {
failing();
}
});
it('clears the composer after a successful turn:send and never falls back to the legacy message event — Task Five group 5', async () => {
await advertiseTurnSendGeneration();
const restore = installRandomUUID(() => PAGE_UUID);
try {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'ship it');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
expect(sends).toHaveLength(1);
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
// On a successful send the composer clears.
expect(textarea.value).toBe('');
} finally {
restore();
}
});
it('sends the freshly persisted selection as a nested turn:send tuple after the user changes provider/model — never a stale default or flat fields — Task Five group 5', async () => {
await advertiseTurnSendGeneration();
const restore = installRandomUUID(() => PAGE_UUID);
try {
// Change the selection away from the mount default and let it persist.
const providerSelect = container.querySelector(
'select[aria-label="Provider"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(providerSelect, 'anthropic');
});
const modelSelect = container.querySelector(
'select[aria-label="Model"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(modelSelect, 'anthropic:claude');
});
await flushAsync();
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'routed');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
expect(sends).toHaveLength(1);
// The nested tuple reflects the CURRENTLY persisted selection, not the
// mount default {openai, gpt-5}, and never flat provider/model fields.
expect(sends[0]?.payload).toEqual({
conversationId: 'c1',
content: 'routed',
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
idempotencyKey: PAGE_UUID,
});
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
} finally {
restore();
}
});
it('disables send until a selection has persisted — no send with an unset selection', async () => {
await remountWithFetch(harnessFetch(null));
-50
View File
@@ -1,50 +0,0 @@
# Administrator Guide
> **Status:** Partially migrated. Current SSO and local upgrade/recovery procedures are available; held procedures are labeled non-operative.
This book is the canonical home for installation, configuration, deployment, routine operations, security controls, incident response, and recovery. User workflows belong in [`USER-GUIDE/`](../USER-GUIDE/); implementation and contributor material belongs in [`DEVELOPER-GUIDE/`](../DEVELOPER-GUIDE/).
## Start here
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
- [Documentation sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary.
- [Product requirements](../PRD.md) — normative requirements, currently marked draft.
- [Operations index](operations/README.md) — current local procedures and explicitly held operational outlines.
- [Security index](security/README.md) — current SSO provider configuration.
## Chapter map
| Chapter | Scope | Status |
| ------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. |
| `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. |
| `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. |
| [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery is current; connector lease operations are held. |
| [`security/`](security/README.md) | Authentication, authorization, SSO, secrets, and security controls. | SSO provider guide is current; other pages are planned. |
| `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. |
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
## Evidence — not current operator guidance
- [`P8-003 performance report`](../reports/qa/p8-003-performance-optimization.md) — historical performance evidence; implementation alignment is partial, and production metrics remain unverified. It is not an operational SLO or runbook.
## Migration backlog — not current operator guidance
These are source candidates, not verified runbooks:
- `_old_structure/guides/admin-guide.md` — quarantined historical source; verify claims before promotion.
- `_old_structure/guides/deployment.md` — quarantined historical source; deployment commands and assumptions remain held.
- See the [documentation catalog](../reports/documentation/2026-08-10-docs-catalog-audit.md) for file-level dispositions.
Do not treat a migration candidate as current until its commands, paths, permissions, and safety status are checked against source and tests.
## Authoring boundary
New administrator documentation belongs under one of the chapter directories above. Operationally sensitive pages must identify prerequisites, ownership, source-of-truth dependencies, and whether any procedure is current, illustrative, held, or non-operative.
## Related
- [[README|Documentation contract]]
- [[PRD|Product requirements]]
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
-19
View File
@@ -1,19 +0,0 @@
# Administrator Operations
> **Status:** Partially migrated. Procedures explicitly identify whether they are current or held.
## Current procedures
- [Upgrade safety and recovery](upgrade-safety-and-recovery.md) — installed-CLI and local-PGlite upgrade, rollback, and framework-configuration recovery.
## Held procedures
- [Mos connector lease operations](mos-connector-lease-operations.md) — non-operative M1 outline while the gateway policy remains deny-all and no connector is activated.
A held page is architecture and readiness context, not command authority. PostgreSQL, federated, bare-metal, Compose, Gateway/Web activation, and migration-runner procedures remain outside the current local route unless a later page explicitly removes the hold with verified evidence.
## Related
- [[ADMIN-GUIDE/README|Administrator guide]]
- [[ADMIN-GUIDE/security/README|Administrator security]]
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
@@ -1,79 +0,0 @@
# Mos Connector Lease Operations — M1
> **Status:** Held / non-operative.
> **Operational authority:** None. This page is a future runbook outline, not a current command, endpoint, migration, or activation procedure.
> **Hold condition:** The gateway's `DenyConnectorLeasePolicy` remains the default policy and rejects every lease and grant operation. No connector is activated by this page.
## Current state
M1 provides an implemented lease, fencing, audit, and gateway policy boundary. It does not currently provide an operator-facing lease endpoint, activate a connector, cut over a channel, or connect an existing runtime provider to `ConnectorExecutionContext`. The default gateway module is deny-all, so operators must not treat the schema or internal service as an available lease-control surface.
There is no current operator command sequence to acquire, renew, take over, release, or grant connector authority. Do not attempt to operate the held procedure through direct database writes or by bypassing the gateway policy. Any future activation requires a separately approved server-side policy, concrete adapter, downstream fencing design, and an updated operational runbook.
## Held future procedure — not current command authority
The following records the intended shape of a later runbook. It is deliberately non-operative while deny-all remains:
### Events and evidence to monitor later
If an authorized policy and adapter are activated in a future work package, correlation IDs may be used to inspect `connector_lease_audit_log` events:
| Event | Meaning |
| ---------- | ---------------------------------------------------------------------------------- |
| `acquire` | First holder inserted for an unused binding |
| `renew` | Current holder heartbeat extended the TTL |
| `takeover` | Authorized compare-and-swap replaced the holder and incremented epoch |
| `release` | Current holder explicitly relinquished authority |
| `expiry` | An expired current lease was observed |
| `reject` | Policy, compare-and-swap, expiry, scope, or fencing validation denied an operation |
Audit records are metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output. The current schema does not independently enforce append-only storage; database-level protection remains a future hardening requirement.
### Held incident-review outline
For a future suspected duplicate or stale connector effect, an approved operator procedure would:
1. correlate the attempted operation with its `reject`, `takeover`, or `expiry` event;
2. compare the durable row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context;
3. treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative rather than retrying it as the old holder;
4. use only the authorized takeover path with the observed expected epoch, never ordinary acquire, for an expired or released row; and
5. preserve evidence without assuming lease fencing provides exactly-once replay safety if an external effect may already have occurred.
These are held review requirements, not instructions to bypass the current deny-all policy.
### Held migration and rollback notes
The checked-in `0016_salty_morlocks.sql` artifact is additive: it creates the lease and audit tables and indexes without changing existing authorization/session tables. A future database rollout would still require the repository's approved migration, backup, verification, and rollback controls. Application rollback would leave additive lease/audit tables in place; dropping them would destroy evidence and is not an automatic rollback step.
This page does not authorize running migrations, connecting to PostgreSQL, initializing PGlite, or starting a connector. Those activities remain outside this held procedure and subject to repository/runtime gates.
### Held security prerequisites
Before this page could become operative, the activation work would need to demonstrate at least:
- tenant authority derived from authenticated gateway context, not connector request fields;
- authorized policy decisions over normalized identity and scopes, with policy TTL handling based on the requested input and coordinator hard caps still applied;
- explicit takeover authorization and expected-epoch compare-and-swap;
- application-boundary normalization plus any separately approved database constraints or protections;
- validation and rejection audit before adapter side effects;
- a concrete adapter that consumes and propagates the normalized epoch/context; and
- existing authorization and exact-action approval controls remaining in force.
M1 currently satisfies the boundary contract and default-deny posture, not these activation prerequisites.
## Explicit non-goals while held
This page does not authorize or claim:
1. lease administration from the dashboard, CLI, HTTP, SQL console, or a connector;
2. production connector or channel activation;
3. exactly-once delivery, side-effect journaling, checkpoint/handoff recovery, or replay safety;
4. automatic failover, rollback, or stale-effect recovery across Claude, Pi, Codex, Matrix, tmux, or provider sessions; or
5. database-enforced audit immutability or database-enforced application normalization.
The page may be promoted to an operative runbook only after deny-all is intentionally replaced, concrete adapters are reviewed, activation evidence exists, and this hold is explicitly removed by the owning work package.
## Related contract
- [M1 logical identity and fencing decision](../../DEVELOPER-GUIDE/architecture/decisions/mos-runtime-portability-m1.md)
- [MOS-PORT requirements](../../PRD.md#mos-runtime-portability-workstream-mos-port)
@@ -1,248 +0,0 @@
# Upgrade safety and recovery
> **Supported route:** an already installed `mosaic` CLI using the local PGlite
> configuration. This is a filesystem and CLI runbook; it does not activate a
> service or connect to a database.
This page covers the supported upgrade, rollback, health, and recovery checks for
Mosaic framework configuration under `MOSAIC_HOME`. It deliberately does not
turn the repository's deployment or PostgreSQL material into an operative
procedure.
## Support boundary
Use this runbook only when all of the following are true:
- `mosaic` resolves to the installed Mosaic CLI (`command -v mosaic`).
- The active application configuration is the local tier: `tier: "local"`,
`storage.type: "pglite"`, and `queue.type: "local"`.
- `DATABASE_URL` is unset. An inherited PostgreSQL DSN is outside this route and
must be removed before continuing.
- `MOSAIC_STORAGE_TIER` is unset or `local`; standalone and federated overrides
are outside this route.
- No Gateway, Web, Compose, or other service activation is required.
The following routes are **held/non-operative** in this guide:
| Route or operation | Status |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| PostgreSQL-backed `standalone` storage | Held; do not activate or probe it here. |
| Federated storage and peers | Held; do not activate or probe it here. |
| Bare-metal deployment | Held; no service installation or lifecycle action is authorized. |
| Gateway/Web activation or HTTP health checks | Held; `/health`, `/health/ready`, and `mosaic gateway ...` are not evidence for this route. |
| Compose startup | Held; do not start a Compose profile or the full stack. |
| Migration runners and tier migration | Held; do not run `mosaic-db-migrator`, `pnpm --filter @mosaicstack/db db:migrate`, `mosaic storage migrate`, or `mosaic storage migrate-tier`. |
A command being present in the CLI does not make a held route operative.
## Storage and path terminology
Keep these locations separate:
- **`MOSAIC_HOME`** — the framework/operator configuration directory. It
defaults to `~/.config/mosaic` and can be overridden with `MOSAIC_HOME`.
- **PGlite data** — the local, in-process database. The checked-in local config
uses `.mosaic/storage-pglite` as its storage `dataDir`; `.mosaic/queue` is the
local queue directory. These are project data, not framework configuration.
- **Durable upgrade snapshots** — operator-file snapshots stored under
`${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/backups/`. They are outside
`MOSAIC_HOME` and do not contain a PostgreSQL dump.
The configuration tier is named `local`; the storage CLI reports the backend as
`pglite`. PGlite is not PostgreSQL and does not require a PostgreSQL server.
Local PGlite schema setup is adapter-owned; this page does not authorize a
separate migration runner.
## Pre-upgrade health gate
Run these checks from a shell that has no inherited database DSN:
```bash
command -v mosaic
mosaic --version
mosaic config path
test -d "$(mosaic config path)"
env -u DATABASE_URL -u MOSAIC_STORAGE_TIER mosaic storage tier show
env -u DATABASE_URL -u MOSAIC_STORAGE_TIER mosaic storage status
mosaic restore --list
```
For the supported route, the storage checks should report the `pglite` backend
and say that no network check is needed. `mosaic storage status` reports
`PGLITE_DATA_DIR` when that variable is set; otherwise it reports the CLI's
`:memory:` fallback. That output is an inspection of the CLI environment, not a
claim that PGlite contents are healthy or durable.
If `DATABASE_URL` is set, stop. Do not point it at a local PostgreSQL instance
to make the check pass. If the active configuration is not local/PGlite, stop;
no operative route is documented here.
The supported health gate is intentionally limited to CLI resolution, framework
configuration path, local storage selection, and available upgrade snapshots.
It does not prove Gateway/Web readiness, provider connectivity, queue health, or
PGlite data integrity.
## Safe upgrade procedure
1. **Record the baseline.** Save the output of `mosaic --version`,
`mosaic config path`, and `mosaic restore --list`. Do not copy secrets into a
ticket or report.
2. **Check for updates without installing them:**
```bash
mosaic update --check
```
Exit status `0` means no update was reported; status `2` means an update is
available. Other failures are not a successful health result. The check is a
package-registry check and does not start Mosaic services or access storage.
3. **Run the installed-CLI upgrade when approved:**
```bash
mosaic update
```
The command updates the installed `@mosaicstack/*` packages through npm and,
when the framework package changed or framework drift is detected, re-seeds
framework files in keep mode. Leave the default re-seed enabled. Do not use
`--relaunch` in this runbook: that option can restart durable fleet agents
and is outside the supported no-activation route. `--no-reseed` is also not a
normal upgrade path because it intentionally leaves framework files stale.
4. **Repeat the health gate.** Confirm the CLI version, the same
`MOSAIC_HOME`, the local/PGlite storage selection, and the snapshot listing.
A successful framework upgrade must not be used as evidence that a held
Gateway, Web, PostgreSQL, or federated route is ready.
Do not replace this procedure with a direct edit of `~/.config/mosaic`, a
repository `tools/install.sh` invocation, a Compose startup, or a migration
command. The supported operator entry point for this page is the installed
`mosaic update` command.
## What an upgrade protects
For an existing keep-mode installation, the framework installer takes two
separate snapshots before the file sync:
1. An ephemeral whole-directory snapshot under `${TMPDIR:-/tmp}/` is used to
restore the previous framework directory if the sync is interrupted or
fails. If that restore cannot complete, the installer prints the retained
temporary snapshot path for manual recovery.
2. A durable snapshot captures the existing operator-owned files before the
upgrade at:
```text
${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/backups/pre-update-<UTC timestamp>/
```
Snapshot directories are mode `0700`; captured files are mode `0600`.
Retention is five snapshots by default and can be changed with
`MOSAIC_BACKUP_RETENTION`. A durable snapshot failure is a warning and does
not replace the manifest and crash-rollback protections.
After the keep-mode sync, the installer compares each captured operator file
with its target. If a file was changed or removed unexpectedly, it restores the
snapshot copy and emits a warning. A symlinked parent is not followed; that
case is left for manual recovery from the snapshot path.
Keep mode is manifest-driven and fail-safe for operator paths, but it does not
mean every file under `MOSAIC_HOME` is user-owned. The framework contract files
`CONSTITUTION.md`, `AGENTS.md`, and `STANDARDS.md` are refreshed by upgrades;
keep local policy in the supported local overlays rather than editing those
contract files as rollback data. A durable snapshot is for operator-owned
configuration, not for the installed npm package, framework-owned contracts, or
PGlite data.
## Rollback and recovery
### If the upgrade fails or is interrupted
The framework sync attempts an automatic rollback from its ephemeral snapshot.
Do not delete `MOSAIC_HOME`, the PGlite data directory, or the temporary snapshot
named in the error. Preserve the command output, then run the health gate.
If the installed framework reports that the automatic restore did not complete,
use the durable snapshot procedure below. The durable snapshot is the recovery
pointer that survives a successful upgrade and the removal of the temporary
snapshot.
### Restore operator configuration from a durable snapshot
`mosaic restore` is confirmation-gated and reports counts and relative paths,
not file contents. First list snapshots, then preview the selected timestamp:
```bash
mosaic restore --list
mosaic restore --from <UTC-timestamp> --dry-run
```
If the preview is correct, run the interactive restore:
```bash
mosaic restore --from <UTC-timestamp>
```
Use `--yes` only when the overwrite has been explicitly approved:
```bash
mosaic restore --from <UTC-timestamp> --yes
```
The timestamp is the value printed by `mosaic restore --list`; the command also
accepts the full `pre-update-<timestamp>` name. For a non-default configuration
home, pass the same target explicitly:
```bash
mosaic restore --mosaic-home "$MOSAIC_HOME" --from <UTC-timestamp>
```
After restoring, repeat the health gate. Restore writes only the operator
surface represented by that snapshot. It does not downgrade the installed CLI,
restore framework-owned contract files, restore `.mosaic/storage-pglite`, or
run any migration.
### If local PGlite data is missing or corrupt
Do not use a framework snapshot as a database backup. Do not run a migration
runner, start PostgreSQL, start Compose, or activate Gateway/Web to investigate.
The current CLI does not provide a wired PGlite export/import operation;
`mosaic storage export` and `mosaic storage import` only print direct-copy
guidance. Preserve the configured PGlite data directory and route data
restoration through the held data-layer procedure rather than improvising a
recursive delete or copy.
### Recovery decision tree
- **CLI or framework path is wrong:** stop, verify `command -v mosaic`,
`mosaic --version`, `MOSAIC_HOME`, and `mosaic config path`; do not activate a
held service route.
- **Upgrade failed during framework sync:** use the installer's automatic
rollback first; if it reports incomplete recovery, list and preview the
durable snapshot, then restore it interactively.
- **An operator file changed after a reported-successful upgrade:** select the
pre-upgrade snapshot with `mosaic restore --list`, preview it with `--dry-run`,
and restore only after reviewing the relative-path plan.
- **PGlite data is affected:** preserve the data directory and stop at the
boundary described above. Framework rollback cannot recover database rows.
- **Output requests PostgreSQL, federated, bare-metal, Compose, Gateway/Web, or
a migration runner:** stop; that is a held/non-operative route, not a next
command for this runbook.
## Post-recovery verification
Run the non-mutating checks again:
```bash
mosaic --version
mosaic config path
env -u DATABASE_URL -u MOSAIC_STORAGE_TIER mosaic storage tier show
env -u DATABASE_URL -u MOSAIC_STORAGE_TIER mosaic storage status
mosaic restore --list
```
A green result here means the installed CLI resolves, the framework path is
present, the CLI selects local PGlite without a network probe, and snapshots can
be enumerated. It does not certify database contents or any held deployment
route.
-20
View File
@@ -1,20 +0,0 @@
# Security
> **Status:** Partially migrated. The SSO provider and Discord ingress security pages are current.
This chapter will contain authentication, authorization, SSO, secrets, RBAC, and security-control guidance for administrators.
## Planned pages
- [`sso-providers.md`](sso-providers.md) — current provider configuration, discovery, callbacks, and failure modes.
- [`discord-ingress.md`](discord-ingress.md) — current Discord service authentication, allowlists, bindings, roles, replay, and failure controls.
- `secrets.md` — document general secret handling after source/configuration verification.
- `rbac.md` — document roles and permissions from the canonical implementation.
The current SSO page reflects dynamic provider discovery. The Discord page documents only the verified Discord compatibility boundary; it does not claim Telegram or Matrix parity. Do not revive retired root documents or add frontend feature flags that the web flow does not consume.
## Related
- [`Administrator guide`](../README.md)
- [`API documentation`](../../API/README.md)
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
@@ -1,137 +0,0 @@
# Discord ingress security
> **Status:** Current Discord behavior only. Telegram shared-contract parity, Matrix channel ingress, and a gateway-wide shared adapter registry are not implemented or are not proven by the current source/tests.
>
> **Last verified:** 2026-08-10 against the Discord plugin, gateway ingress/authentication code, and focused tests linked in [Evidence](#evidence).
>
> **Audience:** Administrators provisioning the Discord remote-control boundary.
This page documents the security boundary that exists today. It is not a deployment recipe for Telegram or Matrix, and it does not turn the gateway's lifecycle plugin list into a universal channel registry.
## Security model
Discord ingress has two current layers:
1. **Native Discord admission** in `@mosaicstack/discord-plugin` applies guild/channel/user allowlists, pairing, role, rate, and thread rules before a thread is created or a message is dispatched.
2. **Gateway compatibility admission** authenticates the Discord Socket.IO service, verifies the signed envelope again, re-checks the allowlists and binding, validates the conversation route, rejects replayed native message IDs, and then dispatches the message to the trusted agent configuration.
The current gateway namespace is `/chat`. The Discord plugin connects with a Socket.IO handshake value named `discordServiceToken`; this is distinct from the environment variable name `DISCORD_SERVICE_TOKEN` that supplies the value to the plugin and gateway.
### Admission and authorization order
For an inbound guild message, the current implementation:
1. Ignores bot-authored messages and messages without a guild. Discord DMs are therefore not handled by this ingress path, even though the client requests a direct-message intent.
2. Uses the configured thread parent as the authorization channel for a thread. A normal Discord category parent is never substituted for a text channel.
3. Requires the guild, authorization channel, and user to appear in their respective allowlists.
4. Resolves a configuration-owned binding and paired user. `viewer` cannot send a turn. Ordinary `send` requires `operator` or `admin`; `approve` and `stop` require `admin`.
5. Applies the message and mention-thread rate limits before any thread creation or gateway dispatch.
6. Derives the route from the binding's logical-agent instance and the response channel/thread. The route does not accept a provider, model, harness, process, or runtime-session selector from Discord.
7. Creates or reuses a thread only after the checks above pass.
The gateway then verifies the HMAC-SHA-256 envelope with `DISCORD_SERVICE_TOKEN`, re-applies the allowlists and binding/role check, requires the conversation ID to match the bound logical agent and channel/thread, and claims the native Discord message ID in a bounded replay cache. The default replay cache is in-process, retains IDs for 15 minutes, and is bounded at 10,000 entries; it is not a durable inbox.
For ordinary chat, the gateway attempts persistence and dispatch using `DISCORD_SERVICE_USER_ID`; `DISCORD_SERVICE_TENANT_ID` is used when configured and otherwise ordinary chat falls back to the service user ID as its tenant. The Discord external route is not a UUID, while persisted conversation IDs are UUIDs; no current route-to-UUID mapping proves that ordinary Discord persistence succeeds. The gateway may continue dispatch after a persistence/binding failure, so successful live delivery is not durable-history evidence.
Privileged approval and stop additionally require a configured tenant, a paired `mosaicUserId`, and a previously enrolled durable session. The durable session's logical agent must match the binding before approval or stop is accepted. The approval is consumed once against the exact runtime target; the Discord service account is not substituted for the approving paired user.
The trusted `agentConfigId` in each binding is resolved by the gateway. Its provisioned agent name must exactly equal `instanceId`. Discord cannot choose an arbitrary agent, provider, or model in the message payload, and Discord ingress does not use the general routing engine for a new session.
## Required configuration
The gateway's current plugin factory is in [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts). When `DISCORD_BOT_TOKEN` is present, the following Discord values are required or validated as shown:
| Name | Required/current behavior |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Enables the Discord plugin and supplies the Discord bot credential. |
| `DISCORD_SERVICE_TOKEN` | Required when the bot is enabled. Authenticates the Socket.IO service handshake and signs/verifies ingress envelopes. Treat as a high-entropy secret. |
| `DISCORD_SERVICE_USER_ID` | Required when the bot is enabled. Provisioned Mosaic service principal used for ordinary Discord dispatch and attempted persistence; durable history is not guaranteed. |
| `DISCORD_SERVICE_TENANT_ID` | Not required to start ordinary Discord chat, but required for the `/approve` and `/stop <approval>` control path. Use the provisioned tenant for the service boundary. |
| `DISCORD_GATEWAY_URL` | Base gateway URL. The plugin connects to `${DISCORD_GATEWAY_URL}/chat`; the gateway factory default is `http://localhost:14242`. |
| `DISCORD_GUILD_ID` | Optional guild ID used only by the current project-channel provisioning helper. It is not the message authorization allowlist. |
| `DISCORD_ALLOWED_GUILD_IDS` | Required, comma-separated guild IDs. Empty or missing values fail closed during plugin creation. |
| `DISCORD_ALLOWED_CHANNEL_IDS` | Required, comma-separated parent text-channel IDs. Thread messages are checked against their configured parent. |
| `DISCORD_ALLOWED_USER_IDS` | Required, comma-separated Discord user IDs. This allowlist is checked in addition to `pairedUsers`. |
| `DISCORD_INTERACTION_BINDINGS` | Required, non-empty JSON array of configuration-owned bindings. Malformed or empty data fails plugin creation. |
| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Optional positive integer; default is `30` authorized turns per guild/channel/user window. Zero, negative, and non-integer values are rejected. |
| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Optional positive integer; default is `5` mention-triggered thread routes per guild/channel/user window. Invalid values are rejected. |
`MOSAIC_AGENT_NAME` and `MOSAIC_AGENT_CONFIG_ID` are not substitutes for a Discord binding. The current Discord binding uses `instanceId` and `agentConfigId` inside `DISCORD_INTERACTION_BINDINGS`; do not invent a different environment-based routing contract.
### Binding shape
Use placeholders for identifiers and keep credentials out of the JSON:
```json
[
{
"instanceId": "interaction-agent",
"agentConfigId": "provisioned-agent-config-id",
"guildId": "guild-id",
"channelId": "parent-channel-id",
"pairedUsers": {
"discord-user-id": {
"role": "operator",
"mosaicUserId": "provisioned-mosaic-user-id"
}
}
}
]
```
Each binding requires `instanceId`, `agentConfigId`, `guildId`, `channelId`, and a non-empty `pairedUsers` object. Pairing roles are `viewer`, `operator`, and `admin`. A role-only pairing remains accepted for ordinary non-privileged compatibility, but it has no `mosaicUserId` and cannot authorize the privileged approval/stop path. The guild and parent channel must also be present in their allowlists.
The bot needs permission to view and send messages in the configured channels and to create and send public threads. A category parent is not an authorization boundary. A thread inherits authorization only from its configured parent text channel.
### Secret handling
Supply `DISCORD_BOT_TOKEN` and `DISCORD_SERVICE_TOKEN` through the approved runtime secret mechanism. Do not commit them, put them in binding JSON, or pass them on a command line.
The current `mosaic gateway config --set KEY=VALUE` implementation writes the gateway `.env` file and prints the value in its confirmation; its mask list does not include `DISCORD_SERVICE_TOKEN`. Do **not** use that command for the service token. `mosaic gateway config --edit` exists for local configuration, but production secret provisioning must remain outside the repository and follow the approved secret path.
## Applying configuration safely
These are the current CLI commands exposed by `@mosaicstack/mosaic`; they manage the gateway daemon and do not constitute a Discord protocol:
```bash
mosaic gateway install
mosaic gateway config --edit
mosaic gateway status
mosaic gateway verify
mosaic gateway restart
mosaic gateway logs --lines 50
```
The daemon reads its environment from `~/.config/mosaic/gateway/.env` by default; `MOSAIC_GATEWAY_HOME` can change that home. `mosaic gateway config --set KEY=VALUE` and `--unset KEY` are also implemented for non-secret values. After changing Discord configuration, restart the gateway so the plugin factory is rebuilt. `mosaic gateway status` and `mosaic gateway verify` check the gateway daemon/health surfaces; the current lifecycle host does not expose a channel-specific `healthAll` command, so a green gateway check alone is not proof that Discord is connected.
## Failure and abuse behavior
| Condition | Current result |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Missing service token/user, allowlist, or interaction bindings when Discord is enabled | Gateway plugin creation fails rather than enabling an unconfigured remote-control surface. |
| Invalid optional rate limit | Plugin creation fails; values must be positive integers. |
| Unallowlisted guild/channel/user, unpaired user, or insufficient role | Message is ignored before thread creation and gateway dispatch. |
| Mentioned message cannot create or fetch its requested thread | The message is not dispatched because its response target cannot be honored. |
| Invalid HMAC, malformed envelope, route mismatch, wrong binding, or replayed native message ID | Gateway rejects the ingress without dispatch. |
| Unsafe attachment metadata or URL | Gateway rejects the message before acknowledgement/dispatch. Current bounds include at most 10 attachments, HTTPS URLs without credentials, query strings, or fragments, and bounded ID/name/URL/metadata lengths. |
| Missing `DISCORD_SERVICE_TENANT_ID` for approval/stop | The privileged control handler returns without creating or consuming an approval. |
| Agent configuration ID does not resolve or its name differs from `instanceId` | Gateway refuses to create the Discord-bound session. |
| External route cannot be persisted as a UUID conversation | Gateway may still dispatch live output; durable history and restart/resume continuity are not guaranteed and must not be inferred from delivery. |
## Explicitly not current
- **Telegram:** `TELEGRAM_BOT_TOKEN` and `TELEGRAM_GATEWAY_URL` can instantiate the raw legacy Telegram plugin, but that plugin does not use the shared channel DTOs, Discord-style service authentication, allowlists, pairing, route validation, or a tested gateway security boundary. Do not treat these variables as a secured Telegram equivalent of the Discord configuration above.
- **Matrix:** No current gateway channel adapter, binding, authentication path, or focused channel test establishes Matrix ingress. Matrix-related fleet/runtime code is not evidence of a Matrix channel deployment procedure.
- **Shared registry parity:** The current gateway `PLUGIN_REGISTRY` hosts lifecycle wrappers (`name`, `start`, `stop`, and optional project provisioning). It does not expose a universal channel health/ingress/egress registry. That is follow-up work, not an administrator capability today.
## Evidence
- [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) — Discord allowlists, bindings, roles, thread routing, signed envelope, typed ingress/egress, limits, retry, and health.
- [`plugins/discord/src/index.test.ts`](../../../plugins/discord/src/index.test.ts) — authorization ordering, thread behavior, attachments, stable routes, egress, rate limits, and health.
- [`apps/gateway/src/chat/chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts) — `/chat` authentication, envelope validation, replay, trusted agent selection, ordinary dispatch, approval, and stop.
- [`apps/gateway/src/chat/chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts) — timing-safe service-token and BetterAuth session validation.
- [`apps/gateway/src/plugin/discord-ingress.security.spec.ts`](../../../apps/gateway/src/plugin/discord-ingress.security.spec.ts) — signature, allowlist, replay, attachment, binding, approval, stop, and logical-agent checks.
- [`apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts`](../../../apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts) — control-flow evidence with explicit durable-session pre-enrollment, not proof of ordinary Discord persistence.
- [`packages/mosaic/src/commands/gateway.ts`](../../../packages/mosaic/src/commands/gateway.ts) and [`gateway/config.ts`](../../../packages/mosaic/src/commands/gateway/config.ts) — verified gateway CLI command names and configuration behavior.
- [Channel protocol architecture](../../DEVELOPER-GUIDE/architecture/channel-protocol.md) — canonical shared-contract and parity boundary.
- [Discord conversation workflow](../../USER-GUIDE/workflows/discord-conversations.md) — end-user behavior.
-192
View File
@@ -1,192 +0,0 @@
---
title: SSO Providers
type: runbook
audience: admin
status: current
source_of_truth: false
---
# SSO Providers
Configure optional enterprise single sign-on for Mosaic Stack through Better Auth's generic OAuth integration. The gateway owns provider configuration and discovery; the web application renders only providers reported by the gateway.
> **Current behavior:** Authentik, WorkOS, and Keycloak are supported in the checked-in implementation. Authentik and WorkOS use OIDC. Keycloak supports OIDC and an optional direct SAML login URL. The web application does **not** read `NEXT_PUBLIC_WORKOS_ENABLED` or `NEXT_PUBLIC_KEYCLOAK_ENABLED`; provider buttons are discovered dynamically from the gateway.
## Prerequisites
Before configuring a provider, establish these gateway settings:
- `BETTER_AUTH_URL` — the public base URL used to construct OAuth callback URLs.
- `BETTER_AUTH_SECRET` — a strong secret for Better Auth sessions and tokens.
- `GATEWAY_CORS_ORIGIN` — the web origin or comma-separated origins allowed by the gateway.
Keep client secrets and Better Auth secrets in the deployment secret store. Do not commit them to `.env` files or expose them to the web bundle.
## Provider configuration
### Authentik OIDC
Set all three variables to enable Authentik:
```bash
AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic
AUTHENTIK_CLIENT_ID=...
AUTHENTIK_CLIENT_SECRET=...
```
The implementation derives OIDC discovery and endpoint URLs from `AUTHENTIK_ISSUER`. An optional team-claim label can be exposed in provider discovery:
```bash
AUTHENTIK_TEAM_SYNC_CLAIM=groups
```
The default reported claim is `groups`. The discovery payload reports this claim; verify any downstream membership-sync behavior separately before treating it as an authorization guarantee.
Register this redirect URI with the Authentik application:
```text
{BETTER_AUTH_URL}/api/auth/oauth2/callback/authentik
```
### WorkOS OIDC
Set all three variables to enable WorkOS:
```bash
WORKOS_ISSUER=https://your-company.authkit.app
WORKOS_CLIENT_ID=client_...
WORKOS_CLIENT_SECRET=...
```
Use the WorkOS AuthKit issuer or custom authentication domain, not a raw WorkOS REST API hostname. Mosaic derives the OIDC discovery URL by appending `/.well-known/openid-configuration` to the issuer. WorkOS uses PKCE and issuer validation in the current auth configuration.
An optional team-claim label can be exposed in provider discovery:
```bash
WORKOS_TEAM_SYNC_CLAIM=organization_id
```
The default reported claim is `organization_id`. The discovery payload reports this claim; verify any downstream membership-sync behavior separately before treating it as an authorization guarantee.
Register this redirect URI with the WorkOS application:
```text
{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos
```
### Keycloak OIDC
Use either an explicit issuer or the URL-plus-realm form. The client ID and secret are required in both forms.
Explicit issuer:
```bash
KEYCLOAK_ISSUER=https://auth.example.com/realms/mosaic
KEYCLOAK_CLIENT_ID=mosaic
KEYCLOAK_CLIENT_SECRET=...
```
Derived issuer:
```bash
KEYCLOAK_URL=https://auth.example.com
KEYCLOAK_REALM=mosaic
KEYCLOAK_CLIENT_ID=mosaic
KEYCLOAK_CLIENT_SECRET=...
```
`KEYCLOAK_ISSUER` takes precedence when both forms are present. Keycloak uses PKCE and issuer validation in the current auth configuration.
An optional team-claim label can be exposed in provider discovery:
```bash
KEYCLOAK_TEAM_SYNC_CLAIM=groups
```
The default reported claim is `groups`. The discovery payload reports this claim; verify any downstream membership-sync behavior separately before treating it as an authorization guarantee.
Register this redirect URI with the Keycloak client:
```text
{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak
```
### Keycloak direct SAML fallback
The current web flow supports a direct SAML link for Keycloak when `KEYCLOAK_SAML_LOGIN_URL` is configured:
```bash
KEYCLOAK_SAML_LOGIN_URL=https://auth.example.com/realms/mosaic/protocol/saml
```
This creates a configured Keycloak provider with `loginMode: saml`. The web login button links directly to the supplied URL as `Continue with Keycloak (SAML)`; there is no Better Auth OIDC callback for this mode. The URL must be the provider's valid SAML launch URL for the deployment.
A SAML-only Keycloak configuration does not require the Keycloak OIDC client variables. Do not combine an incomplete OIDC variable set with SAML-only configuration: partial OIDC configuration is rejected during auth setup.
## Callback and discovery contract
Better Auth is mounted at `/api/auth`. OIDC callbacks use:
```text
{BETTER_AUTH_URL}/api/auth/oauth2/callback/{providerId}
```
The gateway exposes provider discovery at:
```text
GET /api/sso/providers
```
The response includes `authentik`, `workos`, and `keycloak` records with:
- `configured` — whether a usable OIDC or Keycloak SAML configuration is present.
- `protocols` — supported protocols for the provider record.
- `loginMode``oidc`, `saml`, or `null`.
- `callbackPath` — the OIDC callback path, or `null` for SAML-only mode.
- `teamSync` — the configured/default claim label exposed to the UI.
- `samlFallback` — whether a direct Keycloak SAML URL is configured.
- `warnings` — partial OIDC configuration warnings when reported.
The web login page filters this response to configured providers. OIDC buttons use Better Auth's `signIn.oauth2` flow. A configured Keycloak SAML fallback is rendered as a direct link. No provider-specific `NEXT_PUBLIC_*_ENABLED` flag is required or consumed.
## Configuration procedure
1. Set `BETTER_AUTH_URL` to the public gateway URL that the identity provider can reach.
2. Set `BETTER_AUTH_SECRET` and the correct `GATEWAY_CORS_ORIGIN` values.
3. Choose one provider configuration above and set its complete required variable group.
4. Register the exact OIDC callback URI with the identity provider, when using OIDC.
5. Restart or redeploy the gateway so it loads the changed environment.
6. Inspect discovery without exposing secrets:
```bash
curl "$BETTER_AUTH_URL/api/sso/providers"
```
7. Open the web login page and confirm that only configured providers are shown.
8. Complete a sign-in and verify the callback returns to the configured application.
## Partial configuration and failure modes
Provider configuration is optional. If no provider variables are set, the gateway can run without SSO providers and the web login page renders no SSO section.
For Authentik and WorkOS, setting only part of the issuer/client ID/client secret group raises a configuration error. For Keycloak OIDC, the client ID, client secret, and either an explicit issuer or a complete URL-plus-realm pair are required. Empty or whitespace-only values are treated as unset.
Common failures:
| Symptom | Check |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Provider does not appear on the login page | Query `/api/sso/providers`; verify the complete provider variable group is present in the gateway environment. |
| OAuth callback is rejected | Compare the registered redirect URI character-for-character with `BETTER_AUTH_URL` and the provider callback path. |
| Provider is shown but sign-in cannot start | Check `loginMode`, issuer discovery, client credentials, and gateway logs. |
| Keycloak SAML button is absent | Set `KEYCLOAK_SAML_LOGIN_URL` to the provider's direct launch URL and reload the gateway. |
| Startup/auth initialization reports missing variables | Remove the partial provider configuration or provide the complete required group. |
| SSO succeeds but team membership is unexpected | Treat `teamSync.claim` as discovery metadata and verify the actual claim mapping and membership-sync implementation. |
Do not enable a provider by adding the obsolete `NEXT_PUBLIC_WORKOS_ENABLED` or `NEXT_PUBLIC_KEYCLOAK_ENABLED` variables. They are not read by the current web application.
## Related
- [Administrator guide](../README.md)
- [Security chapter](README.md)
- [API documentation index](../../API/README.md)
- [Documentation atlas](../../README.md)
-31
View File
@@ -1,31 +0,0 @@
# API Documentation
> **Status:** Scaffold only. The canonical gateway contract has not yet been migrated into this directory.
This directory is the single API documentation boundary. `OPENAPI.yaml` will be the machine-readable contract, and `ENDPOINTS.md` will provide the human-readable endpoint, authentication, permission, and error index. Neither file exists here yet; do not describe this scaffold as a complete API reference.
## Start here
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
- [Documentation sitemap](../SITEMAP.md) — current API transition status and authority-gated backlog.
- [Documentation catalog audit](../reports/documentation/2026-08-10-docs-catalog-audit.md) — current API artifact inventory and migration evidence.
## Contract map
| Artifact | Purpose | Status |
| ---------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |
| `OPENAPI.yaml` | Canonical machine-readable HTTP/WebSocket API contract. | Planned; not present yet. |
| `ENDPOINTS.md` | Human index for endpoint behavior, auth, permissions, and errors. | Planned; not present yet. |
| [`../openapi-tess.yaml`](../openapi-tess.yaml) | Legacy Tess-scoped OpenAPI artifact with 17 paths. | Migration candidate; not the complete gateway contract. |
A contract migration must verify paths, schemas, authentication, permissions, error behavior, and generated/client references before the legacy artifact is retired. Keep scoped contracts explicitly labeled if they remain alongside the consolidated contract.
## Authoring boundary
New API contracts belong here. Use `OPENAPI.yaml` for machine-readable authority and `ENDPOINTS.md` for human-readable constraints that OpenAPI cannot fully express. Guide books may explain usage workflows, but must link back to this directory rather than copying endpoint definitions.
## Related
- [[README|Documentation contract]]
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
- [[ADMIN-GUIDE/security/README|SSO provider chapter]]
-50
View File
@@ -1,50 +0,0 @@
# Developer Guide
> **Status:** Partially migrated. Architecture, lease-broker verification, and channel-adapter authoring pages are current; other contributor chapters remain unmigrated.
This book is the canonical home for architecture, package and application guides, local development, testing, contribution workflow, and integration authoring. User-facing procedures belong in [`USER-GUIDE/`](../USER-GUIDE/); operator procedures belong in [`ADMIN-GUIDE/`](../ADMIN-GUIDE/); API contracts belong in [`API/`](../API/).
## Start here
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
- [Architecture index](architecture/README.md) — current architecture chapter scaffold.
- [Documentation audit](../reports/documentation/2026-08-10-docs-catalog-audit.md) — evidence-based migration inventory.
- [Product requirements](../PRD.md) — normative requirements, currently marked draft.
## Chapter map
| Chapter | Scope | Status |
| ----------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------- |
| [`architecture/`](architecture/README.md) | System model, components, data flow, security model, ADRs, and RFCs. | Partially migrated. |
| `packages/` | Package- and application-level contracts and guides. | Scaffold only. |
| `local-development/` | Safe local setup and development routes. | Scaffold only. |
| `testing/` | Test strategy, verification, and quality gates. | Lease-broker verification boundary is current. |
| `contributing/` | Contribution, review, and delivery workflow. | Scaffold only. |
| `integrations/` | Plugin, provider, and adapter authoring. | Channel-adapter authoring boundary is current. |
### Current contributor pages
- [Lease-broker operations and verification](testing/lease-broker-operations.md) — safe static/test commands plus explicitly held live operations.
- [Channel adapters](integrations/channel-adapters.md) — current shared contracts and Discord reference boundary; future adapter parity is draft.
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
## Migration backlog — not current developer guidance
These are source candidates or stale records, not verified current instructions:
- [`archived TUI PRD`](../archive/tui/PRD-TUI_Improvements.md) — contradicted/stale; it references a missing `packages/cli`, while current TUI code is under `packages/mosaic`.
- [`archived TUI task ledger`](../archive/tui/TASKS-TUI_Improvements.md) — historical task ledger; its status and worktree claims require revalidation.
- `_old_structure/guides/dev-guide.md` — quarantined historical source; verify paths and commands before promotion. See the [documentation catalog](../reports/documentation/2026-08-10-docs-catalog-audit.md) for its disposition.
Do not make a legacy or archived page current by linking it from a chapter as if it were already promoted.
## Authoring boundary
New developer documentation belongs under one of the chapter directories above. Architecture decisions and RFCs must identify their status and authority; executable behavior must be checked against current code and tests.
## Related
- [[README|Documentation contract]]
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
- [[API/README|API index]]
@@ -1,50 +0,0 @@
# Architecture
> **Status:** Partially migrated. The lease-broker security-contract pages below are current references; the remaining architecture pages are still being classified.
This chapter is the canonical home for Mosaic Stack's system model, component boundaries, data and control flow, security model, architecture decisions, and RFCs. It explains why the system has its shape; it does not replace [`PRD.md`](../../PRD.md), [`TASKS.md`](../../TASKS.md), or the API contract.
## Promoted pages
- [`lease-broker-protocol.md`](lease-broker-protocol.md) — authenticated Unix-socket protocol, identity binding, framing, persistence, and lease transitions.
- [`lease-broker-security.md`](lease-broker-security.md) — identity, ancestry, filesystem, whole-class, observer, and named residual security boundaries.
- [`mutator-class-gate.md`](mutator-class-gate.md) — default-deny tool authorization, runtime adapters, launch choke point, and parser assurance boundary.
- [`compaction-revocation.md`](compaction-revocation.md) — Claude/Pi observer lifecycle, runtime generations, revocation, and the bounded residual stale window.
- [`channel-protocol.md`](channel-protocol.md) — current shared channel DTOs and Discord compatibility baseline, with unimplemented adapter work explicitly marked draft.
- [`decisions/mos-runtime-portability-m1.md`](decisions/mos-runtime-portability-m1.md) — current logical identity, connector lease, grant, audit, and fencing decision; connector activation remains held.
These pages are current security-contract references and are consumed by the lease-broker acceptance suites. Their live deployment gaps remain explicitly labeled in the pages; this migration does not change runtime behavior.
## Planned pages
| Path | Purpose | Status |
| ----------------------------------- | --------------------------------------------------------------------- | ------------------------- |
| `system-overview.md` | Platform boundary and major request, event, and agent-runtime flows. | Planned. |
| `component-map.md` | Apps, packages, plugins, and dependency ownership. | Planned. |
| `data-flow.md` | Data, event, and control-plane movement. | Planned. |
| `security-model.md` | Trust boundaries, authority, authentication, and authorization model. | Planned. |
| [`decisions/`](decisions/README.md) | Approved architecture decision records. | Partially migrated. |
| [`rfcs/`](rfcs/README.md) | Proposals and protocol RFCs. | Draft egress RFC indexed. |
### Draft RFCs
- [`rfcs/optional-ai-egress-gateways.md`](rfcs/optional-ai-egress-gateways.md) — proposed model-egress boundary; not approved or integrated.
Promoted pages must be linked here, from [`DEVELOPER-GUIDE/README.md`](../README.md), and from [`SITEMAP.md`](../../SITEMAP.md). Do not create duplicate architecture pages in `docs/mosaic-stack/` or the docs root.
## Migration backlog — not current architecture
- [`docs/README.md`](../../README.md) — current documentation contract and placement rules.
- [`Documentation information architecture design`](../../plans/2026-08-10-docs-information-architecture-design.md) — approved documentation structure decision, not product architecture.
- [`Documentation catalog audit`](../../reports/documentation/2026-08-10-docs-catalog-audit.md) — evidence and migration recommendations, not normative architecture.
## Source-of-truth boundary
Architecture pages explain approved design and current system boundaries. Requirements remain in [`PRD.md`](../../PRD.md); active work remains in [`TASKS.md`](../../TASKS.md); executable behavior remains authoritative in source and tests. Draft proposals belong in `rfcs/` or [`docs/plans/`](../../plans/), with status clearly labeled.
## Related
- [[README|Documentation contract]]
- [[DEVELOPER-GUIDE/README|Developer guide]]
- [[PRD|Product requirements]]
- [[API/README|API index]]
@@ -1,285 +0,0 @@
# Channel protocol architecture
> **Status:** Current shared type contract and Discord compatibility baseline. The shared gateway registry, Telegram parity, Matrix integration, identity-linking, and multi-surface multiplexing described below are draft or unimplemented.
>
> **Audience:** Developers maintaining `@mosaicstack/types`, channel plugins, the gateway chat/plugin boundaries, or future official adapters.
>
> **Last verified:** 2026-08-10 against the source and focused tests listed in [Evidence](#evidence).
>
> **Authority:** Executable source and tests are authoritative for current behavior. This page explains the boundary; it is not a runtime registry, an API contract, a requirements document, or proof that every channel uses the shared DTOs.
## Reading this page
This page intentionally separates three states:
- **Current** — implemented in the repository and supported by the cited tests.
- **Compatibility** — an existing wire path that preserves current behavior but does not yet mean that the shared channel ports are wired through the gateway.
- **Draft** — a design direction or follow-up work item. Draft sections have no implementation authority and must not be used as instructions for operating Telegram, Matrix, identity linking, or cross-surface fanout.
The migration from `docs/_old_structure/architecture/channel-protocol.md` is a documentation correction. It does not add adapters, change gateway behavior, change authentication, or create database objects.
## Authority and evidence boundaries
| Boundary | Current authority | What this page may claim |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Shared channel types and ports | [`channel.dto.ts`](../../../packages/types/src/channel/channel.dto.ts), [`channel-adapter.ts`](../../../packages/types/src/channel/channel-adapter.ts), and their exports | The TypeScript shapes and method signatures that are currently published from `@mosaicstack/types`. |
| Discord native behavior | [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) and [`index.test.ts`](../../../plugins/discord/src/index.test.ts) | The Discord allowlist, pairing, role, thread, ingress, egress, retry, and health behavior covered by source and tests. |
| Discord gateway compatibility | [`chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts), [`chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts), and the focused gateway tests | The signed Socket.IO service path, gateway validation, raw chat events, and current session dispatch behavior. |
| Plugin hosting | [`plugin.interface.ts`](../../../apps/gateway/src/plugin/plugin.interface.ts), [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts), and [`plugin.service.ts`](../../../apps/gateway/src/plugin/plugin.service.ts) | The lifecycle registry that exists today. It is not evidence of a shared `OfficialChannelAdapter` registry. |
| Telegram | [`plugins/telegram/src/index.ts`](../../../plugins/telegram/src/index.ts) and [`package.json`](../../../plugins/telegram/package.json) | The raw legacy behavior that exists. It is not evidence of shared-contract parity or a working authenticated gateway integration. |
| Matrix, identity linking, and multiplexing | No matching current implementation and test boundary was found for the old page's designs | These topics remain explicitly draft/unimplemented here. |
The current source boundaries also distinguish two identities:
1. A channel route carries a configuration-owned logical agent and response destination.
2. The gateway chooses provider, model, and runtime session internally. Durable-session enrollment is separate and is not proven by the external channel route alone.
A route is therefore not a claim that a channel adapter owns or exposes a harness, provider, model, process, or native runtime-session identity.
## Current shared contract
The channel types are exported through `packages/types/src/channel/index.ts` and `packages/types/src/index.ts`. They define a transport-neutral vocabulary, but TypeScript interfaces alone do not prove that every producer or consumer uses that vocabulary.
### DTOs
The current DTO surface is:
| Type | Current shape and boundary |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChannelMetadataValue` | JSON-safe strings, numbers, booleans, `null`, arrays, and nested objects. |
| `ChannelAttachmentDto` | `id`, `name`, `mimeType`, `url`, and optional `sizeBytes`. |
| `ChannelMessageDto` | `id`, `channelName`, `channelId`, `senderId`, `senderKind`, `content`, `contentKind`, `timestamp`, and `metadata`; `threadId`, `replyToId`, and `attachments` are optional. |
| `ChannelAuthorizedPrincipalDto` | Native `channelUserId`, a `viewer`/`operator`/`admin` role, and an optional `mosaicUserId` for privileged gateway policy. |
| `ChannelBindingDto` | Configuration-owned `bindingId`, `channelName`, `workspaceId`, `channelId`, `logicalAgentId`, and paired `principals`. Credentials are intentionally absent. |
| `ChannelResponseTargetDto` | `channelId` and an optional `threadId`. |
| `ChannelConversationRouteDto` | `bindingId`, `logicalAgentId`, `conversationId`, `channelName`, `authorizationChannelId`, and `responseTarget`. |
| `ChannelIngressDto` | `correlationId`, `nativeMessageId`, an operation, an authorized principal, a normalized message, and a stable route. |
| `ChannelEgressDto` | `correlationId`, a normalized message, and the stable route. |
| `ChannelAdapterHealthDto` | `status` of `connected`, `degraded`, or `disconnected`, with optional `detail`. |
The available operations are `message.send`, `approval.create`, and `session.stop`. The available sender kinds are `user`, `agent`, and `system`; content kinds are `text`, `markdown`, `code`, `image`, and `file`.
### Lifecycle and ports
The shared adapter file currently defines these seams:
```typescript
interface OfficialChannelAdapter {
readonly name: string;
start(): Promise<void>;
stop(): Promise<void>;
health(): Promise<ChannelAdapterHealthDto>;
}
interface ChannelIngressPort {
receive(ingress: ChannelIngressDto): Promise<void>;
}
interface ChannelEgressPort {
send(egress: ChannelEgressDto): Promise<void>;
}
```
`ChannelDeliveryError` currently has only these codes: `invalid_route`, `destination_unavailable`, and `delivery_failed`. The type surface does not define a revoked-auth error code or an executable protocol version `1.0.0`.
### Stable route rule
`ChannelConversationRouteDto` deliberately omits provider, harness, model, process, and native runtime-session fields. The Discord implementation derives its current conversation address as:
```text
<logical-agent-id>:discord:<response-channel-id>
```
and derives its binding address from the configured guild, parent channel, and logical-agent instance. The gateway validates the expected Discord conversation address before dispatch. This is a route-integrity rule, not a claim that the shared DTO is already the gateway's universal session API.
### What is and is not wired today
The Discord class implements both `OfficialChannelAdapter` and `ChannelEgressPort`, and accepts an optional `ChannelIngressPort` dependency. The direct ingress seam is exercised by the Discord tests. However, the gateway host currently registers `IChannelPlugin` objects with only `name`, `start`, `stop`, and optional project provisioning. Its `PLUGIN_REGISTRY` is an array of those lifecycle wrappers; it does not expose `health()`, `ChannelRegistry.healthAll()`, or shared port wiring.
The gateway's current output path is also still Socket.IO event streaming (`agent:start`, `agent:text`, and `agent:end`). No gateway service in the cited implementation produces a `ChannelEgressDto` for a registered adapter. The shared ports are therefore current contracts and a tested Discord seam, not a completed gateway-wide adapter architecture.
## Current Discord compatibility path
Discord is the current reference implementation for the shared contract and the compatibility path. Its behavior is split between native Discord translation in the plugin and gateway-side validation/dispatch.
### Native ingress and authorization
For an inbound Discord message, the plugin currently:
1. Ignores bot-authored messages and messages without a guild.
2. Uses the configured parent text channel as the authorization channel only when the message is in a thread. A normal channel's category parent is not substituted for the channel itself.
3. Applies default-deny guild, channel, and user allowlists.
4. Resolves a configuration-owned binding and paired user role before creating a thread or dispatching to the gateway. `viewer` cannot send turns; approval and stop are admin operations.
5. Applies per-user/channel message and mention-thread rate limits before Discord thread creation or gateway dispatch.
6. Builds a stable route from the configured logical-agent instance and the response channel/thread.
7. Normalizes the authorized turn to `ChannelIngressDto` when a direct `ingressPort` dependency is supplied.
The normalized `ChannelMessageDto` currently includes:
- `channelName: "discord"`;
- the response channel as `channelId`;
- the Discord author as `senderId` and `senderKind: "user"`;
- `markdown` for non-empty text, or `image`/`file` for attachment-only input;
- attachments mapped to `ChannelAttachmentDto`; and
- `metadata` containing `channelMessageId` and `guildId`.
The implementation does **not** currently populate `channelType`, mentions, embeds, or `replyToId` in that normalized metadata. The old page's broader Discord metadata table must not be treated as current behavior.
### Direct shared ingress versus compatibility envelope
When a direct port is present, the plugin calls `ChannelIngressPort.receive()` with the complete normalized ingress DTO. In the current gateway-hosted path, the plugin instead signs a compatibility envelope and emits one of these Socket.IO events:
| Shared operation | Compatibility event | Current envelope boundary |
| ----------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `message.send` | `message` | Correlation ID, native Discord message ID, guild/channel/user IDs, conversation ID, content, optional thread ID, and attachments. |
| `approval.create` | `discord:approve` | The same signed Discord identity and route fields, carrying the approval command. |
| `session.stop` | `discord:stop` | The same signed Discord identity and route fields, carrying the stop command. |
The signature is HMAC-SHA-256 over the ordered envelope payload using the injected Discord service token. The token is used for service authentication and is not part of the protocol payload.
### Gateway validation and dispatch
The gateway exposes the `/chat` Socket.IO namespace. A Discord connection authenticates with `discordServiceToken`; ordinary clients use a BetterAuth session. For Discord service messages, the gateway:
1. Verifies the signed envelope with `DISCORD_SERVICE_TOKEN`.
2. Re-applies the configured guild, channel, and user allowlists.
3. Resolves the configured binding and operation role.
4. Checks that the conversation ID matches the bound logical-agent instance and channel/thread.
5. Rejects a repeated native Discord message ID through the bounded replay protector.
6. Reconstructs a gateway `ChatSocketMessageDto` containing the conversation ID, content, and validated attachments.
7. Uses the configured Discord service principal/tenant for ordinary chat dispatch and the paired `mosaicUserId` for privileged approval/stop policy where required.
8. Selects the trusted `agentConfigId` from the binding and verifies that the provisioned agent name matches the binding's logical-agent instance.
This path is intentionally described as compatibility: the gateway receives a signed Discord envelope and reconstructs chat input; it does not currently receive a complete `ChannelIngressDto` from the host registry.
### Persistence and durability boundary
The Discord `conversationId` is an external route string such as `<logical-agent-id>:discord:<channel-or-thread-id>`. Persisted conversations and messages use UUID conversation IDs. No current route-mapping layer was found that resolves the external route to a generated UUID before ordinary Discord writes. The gateway catches persistence/binding failures and may continue dispatch, so live output does not prove durable history or restart/resume continuity.
The focused cross-surface integration test explicitly pre-enrolls a durable session before exercising control flow. It does not prove that a fresh ordinary Discord message creates durable conversation/message rows. Current architecture claims are therefore limited to authenticated routing and live delivery. Durable Discord continuity requires a route-to-UUID mapping, observable persistence failures, ordinary-ingress enrollment where required, and a fresh-database restart test.
### Thread and conversation behavior
| Inbound case | Current route and side effect |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Authorized untagged message in a parent channel | Uses the parent channel as the response target; no thread is created. |
| Authorized bot mention in a parent channel | Creates a public thread, or reuses the thread already attached to that message, and routes the response to that thread. |
| Authorized follow-up in an existing thread | Authorizes against the configured parent channel and keeps the existing thread; it does not create a nested thread. |
| `/approve` or `/stop <approval>` | Uses the current parent/thread route and requires an already enrolled durable session; ordinary chat does not prove enrollment. |
| Requested thread creation fails | Does not dispatch the message, because the requested response target cannot be honored. |
### Discord egress and health
`DiscordPlugin.send()` is a typed egress implementation, even though the current gateway does not wire it as a universal `ChannelEgressPort`. It:
- rejects a forged or route-misaligned conversation before looking up a Discord destination;
- rejects a missing destination with `destination_unavailable`;
- chunks text at a Discord-safe 1,900-character boundary;
- retries transient rate-limit, server, and network failures up to three attempts;
- derives one stable nonce per correlation/chunk and sends with Discord's enforced nonce option; and
- does not retry permanent delivery failures.
The plugin reports `connected`, `degraded`, or `disconnected` from Discord client readiness and gateway socket connectivity. The health method is tested without exposing provider or runtime state. Outbound delivery uses Discord's native send operation; code-content wrapping from the former page is not implemented.
Agent output reaches the plugin through the current `agent:start`/`agent:text`/`agent:end` Socket.IO events. The plugin buffers the text by conversation ID and calls its typed Discord egress method when the stream ends.
## Draft: shared adapter registry and gateway wiring
**Status: Draft / unimplemented.**
The gateway does have a startup `IChannelPlugin[]` registry, but that registry is a lifecycle host for the current Discord and Telegram wrappers. It does not register `OfficialChannelAdapter` instances, inject `ChannelIngressPort` and `ChannelEgressPort` through a common gateway service, expose adapter health, or implement a dynamic `ChannelRegistry` with `getAdapter`, `listAdapters`, and `healthAll` semantics.
The former page's claim that adapters are already registered uniformly, or that new adapters can be added without channel-specific gateway branches, is not current. The gateway still has Discord-specific authentication, envelope, approval, stop, replay, and binding branches.
A future implementation may define a registry and host lifecycle, but that work must first specify:
- ownership and injection of ingress and egress ports;
- health and failure semantics;
- binding and credential loading boundaries;
- compatibility behavior for existing Socket.IO clients; and
- tests proving that an adapter cannot bypass gateway authorization or route validation.
Until then, this section is design context only.
## Draft: Telegram shared-contract parity
**Status: Raw legacy adapter exists; shared protocol parity and authenticated gateway participation are unimplemented/unproven.**
The current Telegram plugin is not an `OfficialChannelAdapter` implementation. Its source currently:
- launches a Telegraf bot and a Socket.IO client;
- accepts only messages with a text field and ignores attachment-only messages;
- maps each Telegram chat ID to `telegram-<chatId>`;
- emits a raw `{ conversationId, content, role: "user" }` object rather than `ChannelIngressDto`;
- has no shared DTO import, channel binding, principal/role policy, native message ID, attachment mapping, route validation, or health method; and
- sends plain `sendMessage` responses in chunks, without the former page's claimed MarkdownV2, photo, or document handling.
The Telegram Socket.IO client does not provide the Discord service token or a BetterAuth session in its connection options. The source therefore does not establish participation in the gateway's current authenticated connection path. The package's test script uses `--passWithNoTests`, and no package test file is present in this checkout.
Future Telegram parity is draft work. It would need an explicit identity/authentication boundary, shared ingress normalization, binding and operation policy, route-safe egress, health reporting, and focused tests before this page could describe Telegram as an official shared-contract adapter.
## Draft: Matrix integration
**Status: Draft / unimplemented in the channel protocol.**
No current gateway adapter, shared-port wiring, channel binding, identity resolver, room/conversation persistence boundary, or focused channel tests were found for the Matrix design described by the former page. The old Conduit choice, appservice registration, room and Space mappings, ghost users, encryption defaults, retention jobs, and agent-room behavior are therefore proposals, not current system behavior.
Those details must not be copied into implementation instructions or treated as deployment requirements. A future Matrix effort must independently decide and implement its homeserver/appservice boundary, authentication, route mapping, persistence, authorization, delivery, and tests.
## Draft: channel identity linking
**Status: Draft / unimplemented.**
The shared contract carries an already-authorized `ChannelAuthorizedPrincipalDto`; it does not implement a generic channel-identity database or linking flow. Discord currently uses configuration-owned allowlists and pairings. A pairing may include a `mosaicUserId` for privileged operations, while ordinary Discord chat dispatch uses the configured service principal and tenant in the gateway.
No current evidence establishes the former page's proposed `channel_identities` table, OAuth/deep-link flow, anonymous-principal behavior, persistent Matrix session, or revocation endpoint. Those are not implied by the optional `mosaicUserId` field and must remain planned work until schema, auth, gateway, and adapter implementations exist together.
## Draft: multi-surface conversation multiplexing
**Status: Partial raw chat-session support exists; the proposed channel-protocol fanout architecture is unimplemented.**
The gateway currently tracks client/conversation sessions and emits raw typed Socket.IO chat events. Focused gateway tests verify isolation of concurrent conversation streams sharing one socket. The chat event contract is `ChatMessagePayload` plus `agent:*` events, not `ChannelMessageDto` fanout.
There is no evidence in the cited current path for the former page's complete `ConversationService` plus Valkey pub/sub topology, canonical cross-surface `ChannelMessageDto` persistence, or Matrix fanout. Concurrent stream isolation must not be presented as multi-surface channel multiplexing.
A future multiplexing design must define canonical message ownership, subscription and fanout boundaries, replay/ordering behavior, conflict semantics, and per-surface authorization before it can become architecture guidance.
## Current limitations and version boundary
The following are intentionally not claimed as current protocol policy:
- A semantic protocol version of `1.0.0`. `packages/types/package.json` currently reports package version `0.0.2`, while `packages/types/src/index.ts` exports `VERSION = "0.0.0"`; no migration machinery is present in the cited channel code.
- A generic revoked-auth `ChannelDeliveryError` code. The current union contains only `invalid_route`, `destination_unavailable`, and `delivery_failed`.
- Structured log records with a universal `{ channel, event, ... }` schema. Current plugin logs are string-prefixed.
- Universal adapter health monitoring by the gateway host. Discord exposes health; the `IChannelPlugin` host does not.
- Complete `ChannelEgressDto` delivery from the gateway to every channel. Discord's current gateway egress remains Socket.IO stream events followed by plugin-side delivery.
These limitations are evidence boundaries, not requests to change implementation in this documentation migration.
## Evidence
Current-contract evidence:
- [`packages/types/src/channel/channel.dto.ts`](../../../packages/types/src/channel/channel.dto.ts) — DTOs, enums, route fields, and metadata shape.
- [`packages/types/src/channel/channel-adapter.ts`](../../../packages/types/src/channel/channel-adapter.ts) — adapter lifecycle, ingress/egress ports, and delivery errors.
- [`packages/types/src/channel/index.ts`](../../../packages/types/src/channel/index.ts) and [`packages/types/src/index.ts`](../../../packages/types/src/index.ts) — export surface and current package `VERSION`.
Discord evidence:
- [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) — native translation, authorization, thread routing, signed compatibility envelope, typed ingress/egress seam, retry, and health.
- [`plugins/discord/src/index.test.ts`](../../../plugins/discord/src/index.test.ts) — direct ingress, routing/thread behavior, authorization ordering, attachments, route-safe egress, retries, chunking, and health.
- [`apps/gateway/src/chat/chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts) — `/chat` namespace, service/session authentication, signed-envelope reconstruction, trusted binding selection, and raw stream egress.
- [`apps/gateway/src/chat/chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts) — Discord service-token and BetterAuth session validation.
- [`apps/gateway/src/plugin/discord-ingress.security.spec.ts`](../../../apps/gateway/src/plugin/discord-ingress.security.spec.ts) — signature, allowlist, replay, binding, attachment, approval, stop, and logical-agent checks.
- [`apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts`](../../../apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts) — focused control-flow test with explicit durable-session pre-enrollment; not ordinary persistence evidence.
Hosting and compatibility evidence:
- [`apps/gateway/src/plugin/plugin.interface.ts`](../../../apps/gateway/src/plugin/plugin.interface.ts), [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts), and [`plugin.service.ts`](../../../apps/gateway/src/plugin/plugin.service.ts) — current lifecycle-only plugin host.
- [`packages/types/src/chat/events.ts`](../../../packages/types/src/chat/events.ts) — raw Socket.IO chat event contracts.
- [`apps/gateway/src/chat/chat.gateway-redaction.spec.ts`](../../../apps/gateway/src/chat/chat.gateway-redaction.spec.ts) — current per-client/per-conversation stream isolation evidence.
Telegram evidence:
- [`plugins/telegram/src/index.ts`](../../../plugins/telegram/src/index.ts) — current raw Telegraf and Socket.IO behavior.
- [`plugins/telegram/package.json`](../../../plugins/telegram/package.json) — package scripts, including `--passWithNoTests`.
@@ -1,15 +0,0 @@
# Architecture Decisions
> **Status:** Current decision index. A decision describes an implemented and accepted boundary; draft proposals belong under `rfcs/` or `docs/plans/`.
## Current decisions
- [Mos runtime portability M1 — logical identity and fencing](mos-runtime-portability-m1.md) — implemented lease, grant, audit, policy, and fencing boundary; connector activation remains held.
Decision pages do not override product requirements, API contracts, or executable behavior. Each page must identify implementation evidence, operational status, and explicit non-goals.
## Related
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
- [[PRD|Product requirements]]
- [[ADMIN-GUIDE/operations/mos-connector-lease-operations|Held connector lease operations]]
@@ -1,96 +0,0 @@
# Mos Runtime Portability M1 — Logical Identity and Fencing
> **Decision status:** Current implemented decision (M1).
> **Operational status:** The lease/fencing boundary is implemented and test-covered; connector activation remains held.
> **Audience:** Mosaic developers, gateway/runtime adapter authors, and security reviewers.
> **Authority:** This page records the current M1 boundary. The executable source and tests are authoritative for behavior; [`PRD.md`](../../../PRD.md) remains authoritative for requirements.
## Decision
M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The core identity is:
```text
(tenant_id, logical_agent_id, binding_id)
```
`logical_agent_id` is a Mosaic logical identifier, not a runtime session identifier. The gateway derives the tenant from authenticated actor scope. The current internal service boundary normalizes the supplied logical-agent identifier and binding; a future public boundary must resolve and authorize those values server-side. Runtime-native session IDs are not part of the lease or execution-grant contract.
A connector is a replaceable holder of authority for one logical binding. It is not the logical agent identity.
## Durable lease model
The additive migration creates `logical_agent_connector_leases` with one unique row per tenant/logical-agent/binding tuple. The current row contains:
- an opaque lease UUID;
- connector ID and normalized allowed scopes;
- a positive decimal fencing epoch exposed by the application and stored as PostgreSQL `bigint`;
- acquisition, heartbeat, expiry, release, and update timestamps.
Initial acquisition is insert-only. An existing active row yields `lease_held`. An expired or released row yields `takeover_required`; ordinary acquisition does not recover it. An authorized takeover compares and swaps the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch.
### Audit and enforcement precision
`connector_lease_audit_log` is an **application-level append-only contract**: the repository has an insert-only audit path and writes credential-safe lifecycle/rejection metadata. The checked-in schema and migration add a table and indexes, but do not add database triggers, permissions, or other database-level protection against `UPDATE` or `DELETE`. M1 therefore does not claim database-enforced immutability; an append-only guarantee at the database security boundary is a later hardening requirement.
Likewise, identifier, scope, and epoch normalization is performed at the application boundary by the shared types/coordinator and gateway service. The database enforces column types, non-null fields, the lease primary key, and the unique tenant/logical-agent/binding index, but it does not independently enforce all application formats or semantics such as positive epochs, canonical scope ordering/deduplication, or constrained identifier patterns. Database shape must not be mistaken for a second normalization layer.
## Execution grants and TTL boundaries
`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. It enforces hard defense-in-depth maxima of five minutes for leases and thirty seconds for grants; constructor options may only tighten those limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, correlation ID, and epoch.
The gateway policy and coordinator have deliberately separate TTL responsibilities:
- `ConnectorLeaseService` passes the request's numeric `ttlMs` to policy as `requestedTtlMs` for acquire, takeover, heartbeat, and grant issuance. Identity and scopes are normalized before policy evaluation, but this TTL is a request input, not a coordinator-normalized or effective TTL.
- Release and read policy checks use `null` because they do not request a TTL.
- After policy authorization, the coordinator validates the TTL as a positive safe integer and rejects values above the applicable hard cap (`300000ms` for leases or `30000ms` for grants). It does not silently clamp an over-cap value.
- A policy may impose a stricter duration limit, but it cannot expand the coordinator cap. A policy implementation must validate the requested value itself if its decision depends on a bounded or canonical TTL.
Grant validation occurs immediately before adapter invocation and rereads the durable current row. Validation denies:
- grants not minted by the current gateway process, including cloned or forged objects;
- expired grants or leases;
- released leases;
- stale epochs or replaced connectors/lease UUIDs;
- missing, cross-tenant, cross-agent, or cross-binding leases; and
- scopes not authorized by both the grant and the current lease.
A gateway restart intentionally invalidates process-local grant provenance. The durable lease and epoch survive, but a fresh grant is required after current-lease and policy validation.
## Adapter boundary and activation state
The normalized `ConnectorExecutionContext` and `FencedConnectorAdapter` contract define the future adapter boundary. When a concrete adapter is activated, it must receive the context only after the coordinator's final validation and must propagate the epoch/context to downstream effect boundaries that need to fence races after gateway validation.
That contract is **not evidence of an activated adapter**. The current production module registers the lease repository, service, and deny-all policy. The coordinator and gateway integration tests use test adapters, but current production runtime providers and channel paths do not consume `ConnectorExecutionContext` or call `executeGrant`. No concrete Claude, Pi, Codex, Matrix, tmux, provider, or channel connector is activated by M1.
`ConnectorLeaseService` is the gateway-owned policy surface. It derives tenant scope from authenticated context, records policy denials, and is internal: no M1 connector-lease HTTP controller or administration endpoint is registered. The default `DenyConnectorLeasePolicy` rejects every lease and grant operation until a separately authorized server-side policy is supplied.
## Current implementation evidence
The current boundary is represented by the following checked-in surfaces:
- Contract and application normalizers: `packages/types/src/agent/connector-lease.dto.ts` and its unit specification.
- Coordinator, TTL caps, grant provenance, reread, and fencing checks: `packages/agent/src/connector-lease.ts` and its unit tests.
- Gateway policy, tenant derivation, and default deny wiring: `apps/gateway/src/agent/connector-lease.service.ts` and `agent.module.ts`.
- Durable repository and compare-and-swap mutations: `apps/gateway/src/agent/connector-lease.repository.ts`.
- Schema and additive migration artifacts: `packages/db/src/schema.ts` and `packages/db/drizzle/0016_salty_morlocks.sql`.
- Gateway integration and repository specifications: `apps/gateway/src/agent/connector-lease.integration.test.ts` and `connector-lease.repository.test.ts`.
These references establish the implemented boundary; they do not imply that a production database, connector policy, or concrete connector is currently active.
## Explicit non-goals
M1 does **not** provide or authorize:
1. Production connector activation, channel cutover, or cross-harness failover/rollback E2E.
2. Concrete Claude, Pi, Codex, Matrix, tmux, provider, or channel adapters, or integration of existing runtime providers with this lease context.
3. A connector-lease HTTP API or caller-controlled tenant authority.
4. Exactly-once connector receipts, a side-effect journal, checkpoint/handoff payloads, replay deduplication, or recovery semantics for an external effect that may already have happened.
5. Database-enforced append-only audit rows or database-enforced copies of application identifier/scope/epoch normalization.
6. A claim that gateway validation alone fences an effect after an adapter has crossed the boundary; downstream adapters and effect journals remain later work.
A valid lease or grant is therefore not a claim of exactly-once delivery, production connector readiness, or completed runtime cutover.
## Related contract
- [M1 connector lease operations — held/non-operative](../../../ADMIN-GUIDE/operations/mos-connector-lease-operations.md)
- [MOS-PORT requirements](../../../PRD.md#mos-runtime-portability-workstream-mos-port)
@@ -1,15 +0,0 @@
# Architecture RFCs
> **Status:** Current proposal index. RFCs are draft design material and have no operational or implementation authority until an approved decision and implementation evidence supersede them.
## Draft proposals
- [Optional AI egress gateways](optional-ai-egress-gateways.md) — proposed model-egress boundary; LiteLLM and Bifrost are not integrated, and Claudex remains experimental harness tooling.
A draft RFC must not be cited as a supported feature, deployment path, or approved architecture decision. Approved implemented boundaries belong under [`../decisions/`](../decisions/README.md).
## Related
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
- [[DEVELOPER-GUIDE/architecture/decisions/README|Architecture decisions]]
- [[PRD|Product requirements]]
@@ -1,371 +0,0 @@
# RFC: Optional AI Egress Gateways
> **Status:** Draft / proposed — not approved, not current, and not integrated.
>
> **Authority:** Non-operative design proposal. This document does not authorize an
> integration, deployment, provider selection, credential flow, or production use.
> It is intentionally an RFC rather than an approved architecture decision.
>
> **Scope:** Optional model/inference egress only. `IProviderAdapter` and
> `AgentRuntimeProvider` are separate boundaries; this RFC does not merge them.
- **Date:** 2026-07-14
- **Related 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, inference
transport, and runtime sessions are separate concerns.
2. A generic AI gateway could eventually improve model routing, budgets, and
observability, but it must not become Mosaic's identity, authorization,
tenant, connector, or orchestration boundary.
The Tess qualification work also found that provider rebinding is not, by
itself, identity-continuous failover. Any future design still needs a logical
agent identity, durable connector lease/fencing, canonical handoff/checkpoint,
exactly-once receipts, concrete runtime adapters, and cross-runtime rollback
validation.
This page is a reclassified migration of the historical egress-gateway
proposal. The move to `docs/DEVELOPER-GUIDE/architecture/rfcs/` does not promote
it to a decision and does not change runtime behavior.
## Current status and non-goals
This RFC describes a possible future integration boundary. It does **not** say
that any of the candidates below is supported by Mosaic Gateway today:
- **LiteLLM is not integrated.** It is only a candidate for a future model-egress
adapter and remains subject to the prerequisites in this RFC.
- **Bifrost is not integrated.** Its routing, virtual-key, and failover features
are research inputs only, not Mosaic authority.
- **Claudex and `claude-code-proxy` are experimental harness tooling, not
gateway egress.** The `mosaic claudex` path runs a Claude Code harness against
a local translation proxy for evaluation. It is not a Mosaic Gateway egress
integration, does not define the `IProviderAdapter` model contract, and does
not define the `AgentRuntimeProvider` runtime/session contract.
In particular, this RFC does not propose that a generic gateway receive channel
traffic directly, own Mosaic sessions, replace the runtime-provider registry,
or become a second authorization or tenant system. No database, service, or
runtime change is implied by this document.
## Corrected contract boundary
The historical proposal conflated two different adapter families. They must
remain separate.
### `IProviderAdapter`: model and inference egress
`IProviderAdapter` is the model-provider abstraction. Its current contract
covers provider registration, model discovery, provider health, and a future
direct completion stream. `ProviderService` aggregates these adapters and
connects model availability to the Pi `ModelRegistry`.
It does **not** represent an agent process or session. It does not own channel
identity, Mosaic actor or tenant identity, logical-agent ownership, runtime
session IDs, runtime attachments, handoff state, or tool authorization. An
optional LiteLLM, Bifrost, or similar model gateway would therefore be a
candidate for this model-egress path only, after approval and qualification.
### `AgentRuntimeProvider`: runtime and session boundary
`AgentRuntimeProvider` is the runtime-neutral session contract. It covers
runtime capabilities and health plus operations such as listing sessions,
reading session trees, streaming a session, sending a message, attaching or
detaching, and terminating a session. Its `RuntimeScope` is derived from
trusted actor, tenant, channel, and correlation context.
The runtime-provider service performs the gateway-side capability, authorization,
approval, and metadata-only audit boundary before invoking a runtime provider.
It is not a model-egress gateway and must not be substituted for
`IProviderAdapter`. A runtime implementation may use model selection or a model
service internally, but that dependency is an explicit composition between two
contracts; it does not make the runtime provider a model provider or make a
model gateway a session provider.
The checked-in contract and implementation references are:
- [`IProviderAdapter` and provider types](../../../../packages/types/src/provider/index.ts)
- [`ProviderService`](../../../../apps/gateway/src/agent/provider.service.ts)
- [`AgentRuntimeProvider`](../../../../packages/types/src/agent/agent-runtime-provider.ts)
- [`RuntimeProviderService`](../../../../apps/gateway/src/agent/runtime-provider-registry.service.ts)
- [`AgentRuntimeProviderRegistry`](../../../../packages/agent/src/runtime-provider-registry.ts)
### Proposed relationship
If a future implementation is approved, the model and runtime paths remain
parallel and explicitly composed at the Mosaic boundary:
```text
Discord / Matrix / CLI / web
|
v
Mosaic Gateway: authenticated actor + tenant, policy, approvals,
logical agent, connector lease/fence, audit, checkpoints,
idempotency, and side-effect receipts
|
+-----+-----------------------+
| |
v v
model request path runtime/session path
ProviderService / routing RuntimeProviderService
| |
v v
IProviderAdapter AgentRuntimeProvider
| |
v v
optional model-egress native or external runtime/session
gateway (future only) transport
|
v
upstream model/provider
```
The optional egress gateway may receive only a validated model request from the
Mosaic model path. It is not a channel ingress, runtime-session transport,
connector owner, or source of Mosaic identity.
## Draft proposal
This RFC proposes, for review only, that Mosaic could support an optional
model-egress gateway through a dedicated `IProviderAdapter` implementation or
adapter-owned model transport. Any such integration would remain subordinate
to Mosaic Gateway policy and would not add a second runtime/session boundary.
Mosaic Gateway would remain authoritative for:
- authenticated actor and tenant identity;
- logical-agent identity, connector binding, lease epoch, and stale-holder
fencing;
- authorization, approval, and model/tool policy;
- runtime/session access through the separate `AgentRuntimeProvider` boundary;
- audit correlation, redaction, retention, and operational evidence;
- canonical handoff, checkpoint, and recovery state; and
- idempotency keys, operation identity, and durable side-effect receipts.
An optional model-egress gateway must not:
- receive channel ingress directly;
- authorize tools, connector ownership, runtime sessions, or approvals;
- define Mosaic actors, tenants, logical agents, or session identity;
- treat downstream virtual keys as Mosaic principals;
- persist raw Mosaic handoffs, channel credentials, or unredacted telemetry;
- bypass adapter capability negotiation or gateway policy;
- retry or fail over an operation whose side-effect state is ambiguous; or
- silently select an unhealthy or unauthorized provider merely to return a
result.
## Candidate assessment
These dispositions are deliberately non-integrated and do not constitute
approval.
### LiteLLM
**Disposition:** Not integrated; future candidate for a formal
`IProviderAdapter` model-egress prototype only.
Potentially useful features include broad provider routing, virtual keys,
budgets, observability, and OpenAI/Anthropic-compatible surfaces. A future
review must verify, rather than assume, the exact ChatGPT subscription OAuth
flow, supported models, provider terms, token storage, encryption, revocation,
refresh, scope, and incident response.
A prototype would also have to prove streaming, tool calls, reasoning controls,
cancellation, tenant isolation, audit-correlation preservation, retry behavior,
and idempotency. Virtual keys must remain downstream credentials and must not
become Mosaic actors or tenants. Channel ingress and connector credentials
would remain 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:** Not integrated; future candidate for governance and routing
research, with subscription OAuth compatibility unverified.
Virtual keys, budgets, rate limits, weighted load balancing, and provider
failover may inform a future Mosaic egress design, but they are not Mosaic
authority. A future review must verify Codex/ChatGPT subscription OAuth rather
than assume API-key compatibility, map all policy to server-derived Mosaic
tenants, prove that failover preserves connector leases and approvals, and
redact request/response telemetry before persistence.
Automatic failover must be disabled or constrained whenever policy, approval,
or side-effect state is ambiguous.
Source references:
- [Bifrost overview](https://docs.getbifrost.ai/overview)
- [Bifrost repository](https://github.com/maximhq/bifrost)
### Claudex / `claude-code-proxy`
**Disposition:** Experimental harness overlay and local translation proxy; not
Mosaic Gateway egress and not an approved provider integration.
The reviewed path runs GPT models inside the Claude Code harness through a
local `claude-code-proxy` that translates Anthropic Messages traffic to a
ChatGPT-subscription (Codex OAuth) backend. It is intended for evaluation and
must not be described as a current Mosaic model gateway, an `IProviderAdapter`
implementation, or an `AgentRuntimeProvider` implementation.
Its constraints remain important: it is not a multi-tenant control plane, it
must not own connector leasing or canonical handoff, and local proxy
credentials and listener exposure require isolation. The current Mosaic
launcher documents its experimental status and isolated Claude configuration:
- [`mosaic` claudex documentation](../../../../packages/mosaic/README.md)
- [`claudex` launch composition](../../../../packages/mosaic/src/commands/claudex.ts)
- [`claude-code-proxy`](https://github.com/raine/claude-code-proxy)
### `teremterem/claude-code-gpt-5-codex`
**Disposition:** Historical recipe; not selected and not integrated.
The reviewed repository uses `OPENAI_API_KEY`, tells previously authenticated
Claude users to log out, and documents a Claude Web Search schema
incompatibility. That does not satisfy the subscription-OAuth plus built-in-
channel continuity requirement observed in the Mos cutover.
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)
## Prerequisites before approval or integration
No candidate may be integrated, enabled as a Mosaic feature, or treated as a
current supported path until all of the following are complete for the exact
provider, adapter, configuration, and deployed revision.
### 1. Recorded approval and governance
- An architecture decision explicitly approves the model-egress scope and
records that `IProviderAdapter` remains separate from `AgentRuntimeProvider`.
- Product/operations ownership, provider terms review, and independent security
review are recorded against the exact change and provider revision.
- The approval identifies allowed providers, models, regions, data handling,
rollback owner, support status, and a time-bounded experimental or production
phase. This RFC itself is not that approval.
- No candidate is advertised as integrated, current, or production-ready while
the approval record is absent or expired.
### 2. Security and credential controls
- A threat model covers prompt/tool data, model output, streaming, cancellation,
retries, failover, SSRF, endpoint authentication, TLS, egress allowlists,
supply-chain risk, provider terms, and denial-of-service behavior.
- OAuth tokens, API keys, virtual keys, refresh tokens, and proxy credentials
have documented ownership, storage, encryption, rotation, revocation,
expiry, least-privilege scope, and incident-response procedures. Secrets do
not enter prompts, logs, traces, audit payloads, or commits.
- Request, response, tool-schema, and error telemetry is classified and
redacted before persistence or external channel delivery. Retention and
deletion are tenant-scoped.
- The adapter fails closed on missing, expired, revoked, malformed, or
unauthorized credentials and on uncertain provider health. Loopback-only
experimental proxies remain process-isolated and cannot become a gateway
bypass.
### 3. Actor and tenant isolation
- Actor and tenant identity are derived from authenticated Mosaic context at
the Gateway; callers cannot select a tenant, logical agent, credential, or
provider principal by supplying an ID.
- Downstream virtual keys and provider account identifiers are mapped to
server-owned tenant policy. They never grant Mosaic authorization and never
replace RBAC, approvals, connector leases, or runtime scope.
- Model configuration, budgets, rate limits, data residency, credential use,
logs, caches, and failure handling are isolated per tenant. Cross-tenant
reads, writes, cache hits, telemetry, and failover are denied and tested.
- The egress adapter receives only the minimum scoped request needed for the
authorized model operation; channel ingress and runtime/session control stay
in Mosaic-owned boundaries.
### 4. Idempotency and side-effect safety
- Mosaic assigns a durable operation ID and idempotency key before an egress
request can cause a tool or external side effect. The provider gateway must
preserve the correlation and idempotency metadata or be wrapped by an
adapter that does so.
- Durable receipts record request, attempt, provider, outcome, and replay state
without storing unredacted content. Retries and failover are allowed only
when the operation contract proves they cannot duplicate a side effect.
- Ambiguous timeout, disconnect, stream-resume, cancellation, and provider
failover outcomes fail closed until the receipt is reconciled. The egress
gateway must not claim exactly-once behavior that Mosaic has not proven.
- Failure injection demonstrates no duplicate tool, connector, channel, or
external side effect across gateway, adapter, proxy, and provider retries.
### 5. Contract and rollback qualification
- Separate contract tests pass for `IProviderAdapter` model registration,
health, streaming, tools, reasoning controls, cancellation, errors, and
audit correlation.
- Separate `AgentRuntimeProvider` contract tests continue to pass for runtime
capability negotiation, session ownership, approval, attach/detach,
termination, and normalized events. A model-egress candidate must not be
used as evidence for runtime/session compatibility.
- A verified rollback to the prior model path is exercised, including revoked
credentials, unhealthy providers, partial streams, and an adapter version
mismatch.
- The exact deployed revision receives independent code and security review,
with terminal-green CI and operator recovery evidence before any activation.
## 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, agent, or runtime authority.
- Automatic retry or failover can duplicate tool and external side effects
unless Mosaic owns operation IDs, durable receipts, and reconciliation.
- Gateway telemetry can contain prompts, tool schemas, and model output;
redaction and retention policy must apply before persistence.
- A localhost translation endpoint must remain loopback-only, process-isolated,
authenticated where applicable, and outside the Mosaic Gateway ingress path.
- A model-egress gateway outage must not weaken Mosaic authorization, lease
fencing, tenant isolation, approval, or runtime-session boundaries.
## Acceptance before any production use
The following are minimum exit criteria for a future, separately approved
implementation; they are not satisfied by this RFC:
1. The recorded approval and provider-terms review cover the exact integration.
2. Credential lifecycle, revocation, rotation, redaction, and incident drills
are documented and exercised.
3. Tenant-bound authorization remains entirely in Mosaic Gateway and passes
cross-tenant negative tests.
4. `IProviderAdapter` contract tests pass without treating
`AgentRuntimeProvider` tests as substitutes.
5. Failure injection proves no duplicate side effects across retries or
provider failover, with durable idempotency receipts.
6. Streaming, tools, cancellation, reasoning policy, errors, and audit
correlation are qualified for the exact model path.
7. Rollback to the prior provider path is exercised and operator-verifiable.
8. Independent code and security reviews approve the exact deployed revision.
## Follow-up
- #754 owns cross-runtime logical identity, checkpoint, receipt, adapter, and
failover work.
- #755 / PR #757 implements the first logical identity and connector
lease/fencing boundary.
- A later issue may prototype LiteLLM or Bifrost behind the model-provider
boundary only after the recorded approval, security, tenant, idempotency,
contract, and rollback prerequisites are met.
- The page remains **Draft / proposed** until an explicit decision changes its
status. Until then, it must not be cited as an approved architecture,
current integration, or operational runbook.
@@ -1,181 +0,0 @@
# Channel adapters
> **Status:** Current shared channel types plus the Discord reference/compatibility implementation. A shared gateway adapter registry, Telegram parity, and Matrix channel integration remain unimplemented or unproven.
>
> **Last verified:** 2026-08-10 against the source and focused tests listed in [Evidence](#evidence).
>
> **Audience:** Developers implementing or reviewing channel integrations.
The gateway remains the policy and runtime boundary. Channel code translates native events, applies its native admission checks, and delivers normalized ingress/egress; it must not choose a provider, harness, process, or native runtime session on behalf of the gateway.
## Current source boundaries
| Boundary | Current authority | Current claim |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Shared contract | [`packages/types/src/channel/channel.dto.ts`](../../../packages/types/src/channel/channel.dto.ts), [`channel-adapter.ts`](../../../packages/types/src/channel/channel-adapter.ts) | DTOs, ports, lifecycle health, and delivery error codes exported by `@mosaicstack/types`. |
| Discord translation | [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) | First adapter implementing the shared lifecycle/egress seam, with an optional direct ingress port and a tested Socket.IO compatibility path. |
| Discord behavior | [`plugins/discord/src/index.test.ts`](../../../plugins/discord/src/index.test.ts) | Current allowlist, pairing, role, thread, route, attachment, replay-envelope, egress, retry, and health behavior. |
| Gateway compatibility | [`apps/gateway/src/chat/chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts), [`chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts) | `/chat` Socket.IO service/session authentication, signed Discord envelope validation, trusted binding selection, raw chat dispatch, and raw stream egress. |
| Host registry | [`apps/gateway/src/plugin/plugin.interface.ts`](../../../apps/gateway/src/plugin/plugin.interface.ts), [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts), [`plugin.service.ts`](../../../apps/gateway/src/plugin/plugin.service.ts) | Lifecycle-only `IChannelPlugin[]` hosting. This is not a universal `OfficialChannelAdapter` registry. |
| Telegram | [`plugins/telegram/src/index.ts`](../../../plugins/telegram/src/index.ts), [`package.json`](../../../plugins/telegram/package.json) | Raw legacy Telegraf/Socket.IO behavior only; no shared-contract or authenticated gateway parity. |
| Matrix | No current channel adapter boundary in the cited implementation | Matrix-related fleet/runtime code is not evidence of a gateway channel adapter. |
Executable source and tests outrank older architecture or quarantine pages. The canonical architecture summary is [`architecture/channel-protocol.md`](../architecture/channel-protocol.md).
## Shared contract
`@mosaicstack/types` currently exports the channel types through [`packages/types/src/channel/index.ts`](../../../packages/types/src/channel/index.ts) and [`packages/types/src/index.ts`](../../../packages/types/src/index.ts).
The lifecycle and port seams are:
```typescript
interface OfficialChannelAdapter {
readonly name: string;
start(): Promise<void>;
stop(): Promise<void>;
health(): Promise<ChannelAdapterHealthDto>;
}
interface ChannelIngressPort {
receive(ingress: ChannelIngressDto): Promise<void>;
}
interface ChannelEgressPort {
send(egress: ChannelEgressDto): Promise<void>;
}
```
The current DTO boundary includes:
- `ChannelMessageDto` for normalized native messages and JSON-safe metadata;
- `ChannelAttachmentDto` for bounded external attachment references;
- `ChannelAuthorizedPrincipalDto` for the already-authorized channel actor and `viewer`/`operator`/`admin` role;
- `ChannelBindingDto` for configuration-owned workspace/channel/logical-agent binding;
- `ChannelResponseTargetDto` for a channel and optional thread;
- `ChannelConversationRouteDto` for binding, logical agent, stable conversation ID, authorization channel, and response target;
- `ChannelIngressDto` for correlation, native message ID, operation, principal, message, and route; and
- `ChannelEgressDto` for correlation, normalized output, and route.
Current operations are `message.send`, `approval.create`, and `session.stop`. Current delivery errors are `invalid_route`, `destination_unavailable`, and `delivery_failed`; there is no shared revoked-auth error code or executable protocol-version contract in this surface.
### Stable route rule
The Discord adapter derives:
```text
<logical-agent-id>:discord:<response-channel-id>
```
The binding address is derived from the configured guild, parent channel, and logical-agent instance. The route intentionally omits runtime provider, harness, model, process, and native runtime-session identifiers. Gateway provider/runtime layers own those identities; durable-session ownership applies only after explicit enrollment and is not implied by the external route.
This is a route-integrity rule, not proof that the gateway has a universal channel session API. A future adapter must derive its route from trusted configuration and native channel/thread identity; it must not accept a caller-selected logical agent or runtime target.
## Current Discord implementation
### Native ingress
`DiscordPlugin` currently:
1. ignores bot-authored and non-guild messages;
2. resolves a thread's actual parent text channel, while leaving normal category parents out of authorization;
3. applies default-deny guild, parent-channel, and user allowlists;
4. resolves configuration-owned `instanceId`/`agentConfigId` bindings and paired-user roles before thread creation or dispatch;
5. applies message and mention-thread limits before side effects;
6. creates/reuses a mention thread or preserves an existing thread target; and
7. maps the event to a `ChannelIngressDto` when an `ingressPort` dependency is supplied.
The normalized message uses `channelName: "discord"`, the response target as `channelId`, `senderKind: "user"`, `markdown` for non-empty text, `image`/`file` for attachment-only input, mapped attachments, and metadata containing the native channel message ID and guild ID. It does not currently claim a universal metadata shape for mentions, embeds, channel type, or replies.
### Socket.IO compatibility path
The gateway-hosted plugin is currently constructed without a direct `ChannelIngressPort` in [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts), so it uses the established compatibility path:
1. `start()` connects to `${DISCORD_GATEWAY_URL}/chat` with `auth.discordServiceToken`.
2. Normal sends emit a signed `message` envelope; approval and stop emit `discord:approve` and `discord:stop` envelopes.
3. The HMAC-SHA-256 signature covers the ordered Discord payload using `DISCORD_SERVICE_TOKEN`.
4. The gateway verifies the service token/signature, allowlists, binding/operation role, stable conversation ID, attachment bounds, and replay key before dispatch.
5. The gateway selects the binding's trusted `agentConfigId` and verifies its name equals the logical-agent instance. It attempts normal conversation binding/persistence before dispatch, but the external route is not a UUID and no route-to-UUID mapping currently proves durable ordinary-chat history.
6. Agent output currently returns as raw Socket.IO `agent:start`, `agent:text`, and `agent:end` events. The plugin buffers the text and calls its typed Discord egress at stream end.
This compatibility path carries enough normalized identity to preserve current security and routing behavior, but it is not a gateway-produced `ChannelIngressDto`/`ChannelEgressDto` flow through a shared host registry. The gateway can continue live dispatch after persistence failure; adapter authors must not claim durable continuity until external routes map to UUID conversations and fresh-message restart tests pass.
### Egress and health
`DiscordPlugin.send()` is a typed `ChannelEgressPort` implementation. It validates the route and message alignment before destination lookup, sends at a 1,900-character boundary, retries transient 429/5xx/network failures up to three times, uses one deterministic enforced nonce per correlation/chunk, and does not retry permanent failures. It reports `connected`, `degraded`, or `disconnected` from Discord client readiness and gateway socket connectivity.
The host wrapper currently exposes only `name`, `start`, `stop`, and optional project provisioning. It does not expose `health()`, inject the direct ingress port, produce `ChannelEgressDto` values from the gateway, or provide `healthAll`/`getAdapter` semantics.
## Adapter authoring boundary
For a future official adapter, preserve these current architectural boundaries:
1. Normalize native messages to the shared DTOs and preserve native message ID, correlation ID, channel/thread identity, attachments, and response target.
2. Enforce native guild/room/channel/user/pairing/role policy before thread/room creation or gateway dispatch.
3. Select the logical agent from trusted configuration; never from a caller-controlled provider, model, harness, or route field.
4. Keep the route stable when the gateway changes runtime provider or harness.
5. Validate egress route and destination before sending; bound chunks, retries, and attachment metadata.
6. Return sanitized errors and expose non-throwing lifecycle health for ordinary disconnected state.
7. Add happy-path and failure-path tests for authorization ordering, replay/idempotency, route integrity, native side effects, egress, reconnect, and health.
These are implementation constraints for future work, not evidence that the missing shared registry already exists.
## Parity status
### Telegram: raw legacy adapter, not shared parity
The current Telegram source:
- launches Telegraf and a Socket.IO client;
- reads `TELEGRAM_BOT_TOKEN` and `TELEGRAM_GATEWAY_URL` through the gateway plugin factory, whose URL default is `http://localhost:14242`;
- accepts text messages only and ignores attachment-only messages;
- maps each Telegram `chat.id` to `telegram-<chatId>`;
- emits a raw `{ conversationId, content, role: "user" }` object rather than `ChannelIngressDto`;
- has no shared DTO import, channel binding, principal/role policy, native message ID, attachment mapping, route-safe egress, or health method; and
- has no package test file in this checkout; its script is `vitest run --passWithNoTests`.
Its Socket.IO connection does not send the Discord service token or a BetterAuth session. A configured `TELEGRAM_BOT_TOKEN` therefore must not be described as an authenticated official channel. Shared Telegram parity requires a separate implementation and focused security/contract tests.
### Matrix: no current channel adapter
No current gateway adapter, shared-port wiring, channel binding, identity resolver, persistence boundary, authentication path, or focused channel test establishes Matrix as a Mosaic channel. Matrix code elsewhere in the repository belongs to other transport/runtime work and must not be promoted into channel-adapter instructions without a separate contract and evidence.
### Shared registry: draft/unimplemented
The current `PLUGIN_REGISTRY` is an array of `IChannelPlugin` lifecycle wrappers. It is not a registry of `OfficialChannelAdapter` instances and does not inject `ChannelIngressPort`/`ChannelEgressPort`, aggregate adapter health, or remove the gateway's Discord-specific auth/envelope/approval/stop/replay branches.
A future registry must specify binding and credential ownership, ingress/egress injection, health/error semantics, compatibility with the existing Socket.IO clients, and tests proving that adapters cannot bypass gateway authorization or route validation before it can be documented as current architecture.
## Safe verification commands
The focused package commands used by the current Discord evidence are:
```bash
pnpm --filter @mosaicstack/types build
pnpm --filter @mosaicstack/types typecheck
pnpm --filter @mosaicstack/discord-plugin typecheck
pnpm --filter @mosaicstack/discord-plugin lint
pnpm --filter @mosaicstack/discord-plugin test
cd apps/gateway && pnpm exec vitest run \
src/plugin/discord-ingress.security.spec.ts \
src/chat/chat.gateway-redaction.spec.ts \
src/__tests__/integration/tess-cross-surface.integration.test.ts
```
These commands do not require starting Gateway, Discord, Telegram, Matrix, a queue, or a database. The Discord package test includes its configured coverage thresholds; the gateway command is a focused Vitest run rather than a claim of full repository integration coverage.
## Evidence
- [`packages/types/src/channel/channel.dto.ts`](../../../packages/types/src/channel/channel.dto.ts) — shared DTOs, operations, route fields, and metadata types.
- [`packages/types/src/channel/channel-adapter.ts`](../../../packages/types/src/channel/channel-adapter.ts) — adapter lifecycle, ingress/egress ports, and delivery errors.
- [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) — native translation, auth ordering, compatibility envelope, route-safe egress, retry, and health.
- [`plugins/discord/src/index.test.ts`](../../../plugins/discord/src/index.test.ts) — focused Discord contract and behavior tests.
- [`apps/gateway/src/chat/chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts) — service/session auth, signed envelope validation, replay, trusted agent selection, and raw stream events.
- [`apps/gateway/src/chat/chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts) — timing-safe Discord service-token and BetterAuth session checks.
- [`apps/gateway/src/plugin/discord-ingress.security.spec.ts`](../../../apps/gateway/src/plugin/discord-ingress.security.spec.ts) — gateway security and privileged-operation tests.
- [`apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts`](../../../apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts) — control-flow test with explicit durable-session pre-enrollment; not ordinary Discord persistence evidence.
- [`apps/gateway/src/plugin/plugin.interface.ts`](../../../apps/gateway/src/plugin/plugin.interface.ts), [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts), and [`plugin.service.ts`](../../../apps/gateway/src/plugin/plugin.service.ts) — lifecycle-only host registry.
- [`plugins/telegram/src/index.ts`](../../../plugins/telegram/src/index.ts) and [`plugins/telegram/package.json`](../../../plugins/telegram/package.json) — raw Telegram behavior and no-test script.
- [Canonical channel protocol architecture](../architecture/channel-protocol.md) — shared contract and explicit current/draft boundary.
- [Discord ingress security](../../ADMIN-GUIDE/security/discord-ingress.md) — administrator-facing config and auth claims.
- [Discord conversations](../../USER-GUIDE/workflows/discord-conversations.md) — user-facing routing behavior.
- [Developer Guide](../README.md)
@@ -1,292 +0,0 @@
---
title: Lease-broker operations
type: runbook
audience: developer
status: current
source_of_truth: false
---
# Lease-broker operations
> **Status:** Current for repository inspection and test verification. Live broker
> startup, recovery, lease mutation, service management, and cleanup remain
> **held/non-operative**.
>
> **Operational authority:** This page authorizes only the non-mutating
> inspection and test commands in [Safe static inspection](#safe-static-inspection).
> It does not authorize starting a daemon or systemd unit, connecting to a live
> socket, invoking recovery, promoting or revoking a lease, executing a
> consequential runtime tool, or changing a database.
The lease broker is a Linux-only internal process boundary. Its executable
behavior is authoritative in the [shipped broker daemon](../../../packages/mosaic/framework/tools/lease-broker/daemon.py)
and tests, not in this page. The current architecture references are:
- [Broker protocol](../architecture/lease-broker-protocol.md) — framing,
kernel identity, ancestry, generations, persistence, and state transitions.
- [Lease-broker security](../architecture/lease-broker-security.md) — trust
boundaries, filesystem hardening, observer behavior, and residuals.
- [Whole mutator-class gate](../architecture/mutator-class-gate.md) — default
deny, launch choke points, and broker-owned promotion order.
- [Compaction revocation](../architecture/compaction-revocation.md) — Claude
and Pi lifecycle observers, generation fencing, and the bounded residual.
## Safe static inspection
These are the only operative commands documented here. They inspect checked-in
files or run isolated tests; they do not start a user service, activate a
runtime, connect to PostgreSQL, or mutate repository/product state.
### Source and launch inventory
From the repository root, inspect the current implementation and its permanent
runtime-launch inventory:
```bash
find packages/mosaic/framework/tools/lease-broker packages/mosaic/src/lease-broker packages/mosaic/src/mutator-gate -maxdepth 1 -type f -print | sort
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root . --json
```
The launch inventory is a static completeness guard. A clean result means the
checked-in production launch sites are classified by the guard; it does not
prove that a broker, runtime, or service is running.
### Safe unit and static contract tests
The focused standard-library tests can be run directly:
```bash
python3 packages/mosaic/src/lease-broker/daemon_deadline_unittest.py
python3 packages/mosaic/src/lease-broker/normative_fragments_unittest.py
python3 packages/mosaic/src/lease-broker/receipt_challenge_unittest.py
python3 packages/mosaic/src/lease-broker/context_recovery_unittest.py
python3 packages/mosaic/src/lease-broker/state_store_unittest.py
python3 packages/mosaic/src/lease-broker/framework_skill_portability_unittest.py
python3 packages/mosaic/src/mutator-gate/runtime_tools_unittest.py
python3 packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py
python3 packages/mosaic/src/mutator-gate/version_coupling_unittest.py
```
`recovery_runtime_unittest.py` and `recovery_b1_adversarial_unittest.py` are
also safe when run as tests: they use private temporary daemons, sockets, and
fixtures, never the installed user service or a model stream. They are not
operator recovery instructions:
```bash
python3 packages/mosaic/src/lease-broker/recovery_runtime_unittest.py
python3 packages/mosaic/src/lease-broker/recovery_b1_adversarial_unittest.py
```
The [lease-broker Vitest acceptance suite](../../../packages/mosaic/src/lease-broker/lease-broker.acceptance.spec.ts)
and [mutator-gate Vitest acceptance suite](../../../packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts)
provide additional private-fixture coverage. Test-created child processes and
Unix sockets are disposable test fixtures, not live-service authority.
## Current implementation facts
### Protected paths and persistence
The broker and supervisor source establish these invariants:
| Object | Current contract |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Parent directory | The parent containing the socket and state must already exist and have exactly mode `0700`. |
| Broker socket | The daemon refuses an existing path or symlink, binds a new Unix socket, sets it to `0600`, and removes only the inode it created during normal shutdown. |
| State file | A present state file must be a regular, non-symlink file protected as mode `0600`, no larger than 4 MiB, and valid state-version-1 JSON. Corrupt, incompatible, oversized, or unsafe state refuses startup. |
| State writes | Changes are serialized through a mode-`0600` temporary file, complete-write loop, `fsync`, atomic replace, and parent-directory `fsync`. Post-replace durability uncertainty poisons the store and terminates service processing. |
| Volatile authority | `VERIFIED` leases are not restored as live authority after broker restart. Persisted session identity and valid pending-token state are separate from volatile lease state. |
| Runtime generation | `launch-runtime.py` creates `generation-<broker-session>.state` beside the socket. It is an owner-only, locked, monotonic generation file; unsafe, non-regular, oversized, or non-private state fails closed. |
The resolved socket path is, in order: explicit
`MOSAIC_LEASE_BROKER_SOCKET`, `$XDG_RUNTIME_DIR/mosaic-lease/broker.sock`, or
`/run/user/<uid>/mosaic-lease/broker.sock`. The state file is `state.json` next
to that resolved socket. The production observer transport is a separate
`receipt-observer.sock` by default; `--test-observer-file` is a private test
fixture option, not a production deployment path.
The supervisor implementation can materialize a user unit, wrapper, and
co-located daemon sources under caller-supplied paths, but
[`applyBrokerSupervisor`](../../../packages/mosaic/src/lease-broker/broker-supervisor.ts)
never runs `systemctl`, starts `daemon.py`, or enables the unit. The checked-in
unit and [`start-lease-broker.sh`](../../../packages/mosaic/framework/tools/lease-broker/start-lease-broker.sh)
are therefore implementation inputs, not live activation authority for this
page.
### Protocol and identity boundary
The protocol accepts one UTF-8 JSON object followed by one newline, capped at
64 KiB. The client must half-close its write side after the newline and before
waiting for the response (`shutdown(SHUT_WR)` for POSIX clients or
`socket.end()` for Node). Unterminated, multiple, delayed-second, malformed,
oversized, or deadline-exceeded requests fail closed. This is an internal Unix
socket protocol, not an HTTP/OpenAPI endpoint.
The broker obtains `(pid, uid, gid)` from kernel `SO_PEERCRED`, binds the
session to the anchor's `/proc/<pid>/stat` starttime, and revalidates every
walked ancestor's starttime. A caller cannot choose `session_id`; a sibling or
unrelated process cannot authenticate with another process's session. A higher
runtime generation replaces the prior incarnation and revokes its tokens and
lease authority; a lower generation is stale.
### Lease and tool authorization
The broker is the sole lease writer. The effective default-deny policy is:
- Claude read-only classes: `Read`, `Grep`, `Glob`, `Ls`, and `Find`.
- Pi read-only classes: `read`, `grep`, `find`, and `ls`.
- Both runtimes expose the fixed `mosaic_context_recover` identity as the
constrained recovery exception.
- Every other built-in, unknown, custom, MCP, shell, edit, write, deployment,
provider, or filesystem mutator is consequential and is denied while the
session is not `VERIFIED`.
The normal broker transition is revoke-first and promote-last:
1. `begin_verification` authenticates the broker-minted session and current
generation, revokes existing authority and pending tokens, validates the
exact source construction and binding, and enters a pending state.
2. The receipt challenge and binding are broker-generated. A trusted observer
must provide the exact current-cycle assistant entry; caller-supplied
`latest_assistant_message` is rejected on the public broker socket.
3. Promotion consumes the evidence-backed one-time token before volatile
`VERIFIED` becomes visible. A token or receipt cannot be replayed against a
later cycle.
4. `revoke_lease`, generation replacement, broker restart, or monotonic TTL
expiry removes consequential-tool authority. TTL is positive and capped at
300 seconds.
`launch-runtime.py` is the register-before-`exec` choke point for Claude and
Pi. It performs the activation-capability version check, registers the anchor,
creates the private generation file, exports the broker identity to descendants,
and only then executes the requested runtime. Registration, capability,
generation-file, broker-reply, or `exec` failure denies launch. Claude's raw
`--dangerously-skip-permissions` flag is owned by this wrapper; callers request
only its semantic dangerous mode.
Claude's all-tools `PreToolUse` hook and Pi's `tool_call` handler submit the
runtime-reported tool name to the gate. The gate does not inspect a shell string
to decide that one command is safe. Missing identity, malformed input or reply,
timeout, broker unavailability, unsafe generation state, and denial all fail
closed.
### Lifecycle and recovery boundaries
Claude and Pi lifecycle observers use the same authenticated session and
broker state machine. Compaction revocation and same-PID replacement behavior
are summarized below:
| Runtime event | Current source-backed behavior |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Claude `PreCompact` | Revoke before compaction; a failed hook blocks the lifecycle transition. |
| Claude `SessionStart` matcher `compact` | Revoke again after compacted context starts. |
| Claude `SessionStart` matcher `resume\|clear` | Bump the private generation, then revoke the replacement incarnation. |
| Pi `session_before_compact` | Revoke before compaction; failure cancels the transition. |
| Pi `session_compact` then first `context` | Run one independent post-compaction revoke; a failed post-observer locally blocks later tools until retry. |
| Pi `session_start` reason `reload`, `new`, `resume`, or `fork` | Bump the private generation before revoking and reusing the replacement session. |
| Fired observer with unavailable broker | Advance the private generation as a local fence and return failure; do not continue consequential work. |
The [source-resident context-refresh skill](../../../packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md)
is a thin adapter over `recover-context.py`. Recovery begins with a validated
normative-fragment construction, broker-side revoke-first transition, and a
fresh `PENDING_DELIVERY` challenge. The trusted observer records only finalized
Claude Stop-hook or Pi `message_end` content. Completion supplies no receipt or
challenge argument; the broker obtains its current challenge, checks the exact
observed entry, consumes evidence, and promotes last. Recovery does not accept
normal-path receipt text as replayable authority.
A receipt is only a delivery/liveness prerequisite. It is not a safety,
obedience, comprehension, or residency proof. Absent, malformed,
prefix-truncated, and observably mutated terminal receipts do not promote. A
tail-preserving middle drop is explicitly not receipt-detectable and remains a
server-side/T-C residual.
### Named residual
If both compaction observers are missed while a lease remains unexpired,
consequential tools are **ALLOWED** inside the bounded residual stale window.
The mutator gate makes no within-window action-count or timing claim. After the
monotonic TTL expires, the next consequential tool is **DENIED**. This is
separate from a fired observer that cannot reach the broker, which fails closed
through lifecycle cancellation, a generation fence, and/or the Pi local latch.
Protected-branch controls and required review/CI remain the irreducible
server-side backstop for the T-C total-hook-miss boundary.
## Held future procedure — non-operative
There is no current command authority for the following live procedures. The
sequence below records source-backed intent for a separately approved
activation/recovery work package; it must not be copied into an operator shell.
### Startup and restart outline (held)
1. An activation owner would first materialize the exact framework unit,
wrapper, and co-located source copies, then verify the resolved parent,
socket, state, observer, and generation paths and their no-symlink/private
posture.
2. The approved supervisor would start the daemon only after confirming that
the exact socket path is not owned by another process. `READY` from the
daemon and a live Unix socket would be health evidence; a unit file alone
would not be healthy.
3. A broker crash would require preserving the state file, identifying the
socket owner, and making an explicit restart decision. Restart intentionally
clears volatile `VERIFIED` leases; it is not a way to restore authority.
4. A leftover socket would be handled only after the owning service is
confirmed stopped and the exact path is deliberately reviewed. This page
supplies no deletion, `systemctl`, enablement, or start command.
The source's `daemon.py` argument parser and the user unit show how a future
activation is wired, but neither source file grants this page authority to
invoke that wiring. `applyBrokerSupervisor` is materialization only; the
supervisor source explicitly leaves enable/start as a separate held step.
### Recovery outline (held)
The future adapter flow is: runtime supplies validated construction and current
non-negative epochs; broker performs `begin_recovery` revoke-first and returns
one fresh receipt; the adapter delivers that exact receipt; the authenticated
observer records the finalized assistant entry; and the adapter requests
completion without presenting receipt text or a challenge. Any absent,
malformed, stale, duplicated, or mismatched observation leaves the session
`UNVERIFIED`; retry starts a new recovery cycle.
Claude's adapter is restricted to the exact literal recovery argv shape checked
by the gate. Pi uses only the registered `mosaic_context_recover` tool; Pi
`bash` and all other tools remain gated. Do not manually invoke the revoker to
restore authority, send assistant text through the public broker request, or
reuse a normal-path receipt. The [context-refresh skill](../../../packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md)
and its [runtime boundary tests](../../../packages/mosaic/src/lease-broker/recovery_runtime_unittest.py)
are references for future adapter qualification, not an active operator route.
### Lease, mutator, and incident handling outline (held)
- There is no supported operator CLI or HTTP endpoint for sending raw
`begin_verification`, `promote_lease`, `revoke_lease`, or `authorize_tool`
requests. Do not hand-craft JSON frames, mint tokens, replay receipts, or
treat a successful read-only authorization as a promotion.
- After a runtime exits, its generation file may be removed only after an
approved check establishes that no process for that broker-minted session
remains. Retain stale files during incident analysis; they carry no lease
authority by themselves.
- Corrupt, oversized, symlinked, or non-regular state must be preserved for
review and not overwritten in place. Establishing new state is an explicit
operational decision that invalidates prior sessions and tokens; no recovery
command is supplied here.
- Directory `0700` and socket/state `0600` are same-principal hardening only.
They do not prevent the same UID from unlinking and replacing a socket. A
stronger deployment needs an external protected proxy, ACL, or service
boundary that preserves the peer identity required by `SO_PEERCRED` and
ancestry checks. No such deployment procedure is current here.
## Related source and tests
- [Broker daemon](../../../packages/mosaic/framework/tools/lease-broker/daemon.py)
- [Register-and-exec launcher](../../../packages/mosaic/framework/tools/lease-broker/launch-runtime.py)
- [Mutator gate](../../../packages/mosaic/framework/tools/lease-broker/mutator-gate.py)
- [Recovery command](../../../packages/mosaic/framework/tools/lease-broker/recover-context.py)
- [Lease broker acceptance tests](../../../packages/mosaic/src/lease-broker/lease-broker.acceptance.spec.ts)
- [Mutator gate acceptance tests](../../../packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts)
- [Pi lifecycle tests](../../../packages/mosaic/src/mutator-gate/pi-compaction-lifecycle.spec.ts)
This migration changes documentation placement and verified wording only. It does
not start or change the broker, a runtime, systemd, PostgreSQL, or any other
service.
-222
View File
@@ -1,222 +0,0 @@
# Mosaic Stack Documentation
This directory is the canonical home for Mosaic Stack product, architecture, API, operations, and delivery documentation.
This file is the **documentation contract**. It defines where information belongs, which files are authoritative, how pages connect, and how agents must maintain the documentation set. The structure below is the target structure for the documentation migration; existing content is not considered migrated until it has been classified and moved deliberately.
## Start here
Choose the path that matches your purpose:
- **Understand the product:** start with [`PRD.md`](PRD.md), then use the user guide and the architecture overview.
- **Use Mosaic Stack:** use `USER-GUIDE/`.
- **Install, configure, deploy, or recover Mosaic Stack:** use `ADMIN-GUIDE/`.
- **Change or extend the codebase:** use `DEVELOPER-GUIDE/`.
- **Integrate with the gateway:** use `API/`.
- **Find a page or follow the documentation graph:** use [`SITEMAP.md`](SITEMAP.md).
- **Understand active delivery state:** read [`TASKS.md`](TASKS.md), subject to its single-writer policy.
The root README is an atlas and authoring guide, not a replacement for the user, administrator, developer, or API books.
## Canonical directory structure
The following is the complete target structure. Directories and pages may be created incrementally, but new documentation must use these locations.
```text
docs/
├── README.md # this documentation contract and atlas
├── PRD.md # canonical requirements source
├── TASKS.md # active orchestrator task rollup
├── SITEMAP.md # complete navigation index
├── .obsidian/ # optional Obsidian vault metadata only
├── USER-GUIDE/ # end-user documentation
│ ├── README.md # user-book index
│ ├── getting-started/ # first install/use and quickstarts
│ ├── concepts/ # user-facing concepts and terminology
│ ├── workflows/ # task-oriented user procedures
│ └── troubleshooting/ # user-visible failures and fixes
├── ADMIN-GUIDE/ # operator and administrator documentation
│ ├── README.md # admin-book index
│ ├── installation/ # installation and prerequisites
│ ├── configuration/ # configuration and environment
│ ├── deployment/ # deployment topologies and rollout
│ ├── operations/ # routine operation and observability
│ ├── security/ # auth, RBAC, secrets, and security controls
│ └── recovery/ # incident response, backup, and recovery
├── DEVELOPER-GUIDE/ # contributor and maintainer documentation
│ ├── README.md # developer-book index
│ ├── architecture/ # system model and technical design
│ │ ├── README.md # architecture index
│ │ ├── system-overview.md # platform boundary and major flows
│ │ ├── component-map.md # apps, packages, plugins, and dependencies
│ │ ├── data-flow.md # data, event, and control-plane movement
│ │ ├── security-model.md # trust boundaries and authority model
│ │ ├── decisions/ # ADRs and approved design decisions
│ │ └── rfcs/ # proposals and protocol RFCs
│ ├── packages/ # package- and application-level guides
│ ├── local-development/ # local setup and safe development routes
│ ├── testing/ # test strategy and verification workflow
│ ├── contributing/ # contribution and review workflow
│ └── integrations/ # plugin, provider, and adapter authoring
├── API/ # machine- and human-readable API contract
│ ├── README.md # API documentation index
│ ├── OPENAPI.yaml # canonical OpenAPI contract
│ └── ENDPOINTS.md # endpoint, auth, permission, and error index
├── assets/ # diagrams and documentation media
├── reports/ # evidence and findings; never normative by itself
│ ├── code-review/ # review reports
│ ├── documentation/ # documentation audits and checklists
│ ├── qa/ # QA and verification reports
│ ├── security/ # security reviews and threat evidence
│ └── deferred/ # unresolved or explicitly deferred findings
├── tasks/ # archived task snapshots and learnings
├── plans/ # approved design and implementation plans
├── scratchpads/ # active task-specific working notes
├── releases/ # release notes and compatibility notes
├── archive/ # superseded but intentionally retained docs
└── _old_structure/ # temporary read-only migration quarantine
```
### Directory rules
- `.obsidian/` is optional tool metadata. It is not a content directory. Do not put Markdown pages, reports, plans, task notes, or source-of-truth files there.
- `USER-GUIDE/`, `ADMIN-GUIDE/`, and `DEVELOPER-GUIDE/` are books. Each book must have a `README.md` that links to every chapter and page in that book.
- Each chapter is a directory for one topic area. Each page should cover one concern or workflow.
- `API/OPENAPI.yaml` is the API contract. `API/ENDPOINTS.md` explains details that OpenAPI cannot fully express.
- `reports/`, `tasks/`, `plans/`, `scratchpads/`, `releases/`, and `archive/` are artifact boundaries, not alternative guide books.
- `_old_structure/` is temporary migration quarantine. It is read-only, is not current documentation, and is never a destination for new work.
- `docs/mosaic-stack/` is retired as a content boundary. Do not create new files there. Cross-cutting architecture belongs in `DEVELOPER-GUIDE/architecture/` and navigation belongs here and in `SITEMAP.md`.
- Do not add miscellaneous Markdown files directly under `docs/`. The permitted root files are `README.md`, `PRD.md`, `TASKS.md`, and `SITEMAP.md`; all other content belongs in a scoped directory.
## Where agents must place documents
Classify a document by its primary reader and purpose before creating it. Use this matrix instead of guessing from an existing filename.
| Content | Required location | Examples |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------- |
| Documentation contract and top-level map | `docs/README.md` | Folder rules, source-of-truth policy, authoring workflow |
| Product requirements and acceptance criteria | `docs/PRD.md` or an explicitly scoped workstream PRD | Objectives, scope, requirements, acceptance criteria |
| Active orchestrator task state | `docs/TASKS.md` | Milestone/task rollup; single writer is the orchestrator |
| Documentation navigation | `docs/SITEMAP.md` and the relevant book `README.md` | Page indexes and reader paths |
| User-visible workflow or troubleshooting | `docs/USER-GUIDE/<chapter>/` | Chat, projects, task workflows, user setup |
| Installation, configuration, deployment, operations, security, or recovery | `docs/ADMIN-GUIDE/<chapter>/` | SSO, tiers, secrets, health checks, incident runbooks |
| Architecture, component map, data flow, package design, ADR, or RFC | `docs/DEVELOPER-GUIDE/architecture/` or its relevant developer chapter | System design, protocol decisions, package contracts |
| Local development, testing, contribution, or integration authoring | `docs/DEVELOPER-GUIDE/<chapter>/` | Setup, test commands, plugin development |
| HTTP/WebSocket API contract | `docs/API/OPENAPI.yaml` and `docs/API/ENDPOINTS.md` | Paths, schemas, auth, permissions, errors |
| Diagram, screenshot, or other documentation media | `docs/assets/` or an owning chapter's asset directory | Architecture diagrams, workflow images |
| Approved design or implementation plan | `docs/plans/` | Design docs and task-by-task execution plans |
| Active task working notes and verification log | `docs/scratchpads/<task-id>-<slug>.md` | Assumptions, progress, commands, evidence, blockers |
| Code review, QA, audit, security, or deferral evidence | `docs/reports/<category>/` | Review findings, test reports, security evidence |
| Archived task snapshot or orchestrator learning | `docs/tasks/` | Closed task breakdowns and retained learnings |
| Release notes or version-specific compatibility information | `docs/releases/` | Release summaries, upgrade notes, deprecations |
| Superseded documentation that must remain discoverable | `docs/archive/` | Historical guides, retired proposals, old mission records |
If content seems to fit multiple locations, choose one canonical home and link to it from the other relevant indexes. Do not create copies merely to satisfy multiple audiences.
## Source-of-truth and precedence
Use these rules when documents disagree:
1. **Requirements:** `PRD.md` is the project requirements source. A scoped PRD may add detail, but must link to and remain consistent with the root PRD.
2. **Active work state:** `TASKS.md` is the active orchestrator rollup. Workers read it; they do not rewrite its status or schema unless the orchestrator authorizes that change.
3. **API:** `API/OPENAPI.yaml` is the machine-readable API contract. `ENDPOINTS.md` is the human index and may explain constraints not represented by OpenAPI.
4. **Current behavior:** guide pages describe verified current behavior. If implementation changes, update the affected guide in the same logical change set.
5. **Architecture and decisions:** approved decisions under `DEVELOPER-GUIDE/architecture/decisions/` and RFCs explain why the system has its current shape. They do not silently override the PRD or API contract.
6. **Evidence:** reports record what was reviewed, tested, or deferred. They are evidence, not a substitute for current requirements or operational instructions.
7. **Plans:** plans describe intended work. After delivery, update the canonical guide, contract, or decision page rather than treating the plan as the current behavior.
8. **Scratchpads:** scratchpads are working memory and verification records. They are not product documentation and must not become hidden requirements.
9. **Archive:** archived pages are historical. Every retained page should identify its status and replacement, if one exists.
10. **Code is authoritative for executable behavior:** documentation must not claim commands, paths, APIs, or safety properties that the current code and tests do not support. When the desired behavior differs from current behavior, record the desired behavior in the PRD or an approved design and label operational procedures as held/non-operative when necessary.
## Page conventions
Every canonical page should:
1. Use a descriptive lowercase kebab-case filename, except for established root control files and required API filenames.
2. Cover one concern, concept, decision, or workflow.
3. Start with a clear title and a short purpose statement.
4. Identify its audience and lifecycle status when it is more than a simple index.
5. State prerequisites, dependencies, and source-of-truth references.
6. Mark examples and commands as current, illustrative, held, or non-operative when that distinction matters.
7. Include an owner or maintenance responsibility for operationally sensitive content.
8. Link to the relevant book index and related canonical pages.
Recommended front matter for canonical pages:
```yaml
---
title: Human-readable page title
type: guide
audience: developer
status: current
source_of_truth: false
---
```
Allowed `type` values include `guide`, `concept`, `reference`, `decision`, `rfc`, and `runbook`. Allowed `audience` values are `user`, `admin`, `developer`, and `all`. Allowed `status` values are `current`, `draft`, `deprecated`, and `historical`.
Indexes may omit front matter when their purpose is self-evident. A page with normative authority must explicitly identify the authority it owns and the boundaries of that authority.
## Obsidian and link conventions
Mosaic Stack documentation is compatible with Obsidian without making Git-hosted navigation unusable.
- Use Obsidian wikilinks for graph-oriented internal relationships, for example `[[DEVELOPER-GUIDE/architecture/component-map|Component map]]`.
- Use normal relative Markdown links in `SITEMAP.md` and book `README.md` indexes so links render on Gitea/GitHub and other Markdown hosts. Obsidian resolves these links too.
- Use `Related`, `Depends on`, and `Referenced by` sections when a page has meaningful relationships to other pages.
- Use wikilink heading targets when a specific section matters, for example `[[DEVELOPER-GUIDE/architecture/system-overview#Gateway boundary|Gateway boundary]]`.
- Omit `.md` in wikilinks. Use an alias when the path is not a readable label.
- Use standard Markdown links for external URLs, source files, commands, and API paths.
- Link to stable repository-relative paths, not temporary branches, line numbers, or machine-local paths.
- Every current canonical page must be reachable from a book index or `SITEMAP.md`; do not create orphan pages.
- Do not use `_old_structure/` links as current navigation. Historical references must explain why the page is retained and point to its replacement.
## Authoring workflow for agents
For every documentation change:
1. **Search first.** Look for an existing page, requirement, report, task, or scratchpad before creating a new file.
2. **Classify.** Choose the primary audience, content type, lifecycle status, and source-of-truth role.
3. **Choose the canonical home.** Apply the placement matrix; do not place content in the docs root or `docs/mosaic-stack/`.
4. **Write one concern.** Keep the page focused and link to existing pages instead of copying them.
5. **Connect the page.** Add it to the owning book index and `SITEMAP.md`; add `Related`, `Depends on`, or `Referenced by` links where useful.
6. **Record non-trivial work.** Create or update `docs/scratchpads/<task-id>-<slug>.md` with objective, assumptions, progress, commands, risks, and evidence. Active `docs/TASKS.md` changes remain under its single-writer policy.
7. **Verify claims.** Check commands, paths, API schemas, permissions, and status against source and tests. Label held or non-operative procedures explicitly.
8. **Format and review.** Run the repository's Markdown formatting check, inspect links and headings, and review the diff for stale paths or duplicated authority.
9. **Commit a coherent change.** Keep documentation changes with the related code/API/operation change when applicable, and do not include unrelated staged files.
## Migration policy
The initial structure pass defined the target structure without moving or rewriting the existing documentation set. Subsequent migration slices may move or rewrite classified pages deliberately, with repository references and indexes updated together.
- Treat `_old_structure/` as read-only migration quarantine. Do not add new content there.
- Treat the current root-level legacy pages (`openapi-tess.yaml` and the task/mission documents) as migration backlog, not permission to create more root files. The former empty `QUICKSTART.md` placeholder now lives as `USER-GUIDE/getting-started/quickstart.md`; the verified SSO runbook lives under `ADMIN-GUIDE/security/`; historical evidence such as the P8-003 performance report belongs under `reports/qa/`.
- Candidate destinations include `USER-GUIDE/getting-started/`, `ADMIN-GUIDE/security/`, `DEVELOPER-GUIDE/architecture/`, `API/`, `tasks/`, `plans/`, and `archive/`; classify each page before moving it.
- `docs/fleet/` is an executable documentation contract consumed by current source and tests; keep that complete book at its canonical path. Federation and other authority surfaces may also have live consumers. Update any such path only through an explicitly coordinated source/test and authority migration.
- When a page is moved, update all repository links, source comments, tests, book indexes, and `SITEMAP.md` in the same logical change.
- Preserve historical evidence in `reports/`, `tasks/`, `releases/`, or `archive/` instead of mixing it into current guides.
- Remove the empty `docs/mosaic-stack/` boundary only after confirming no source, test, or documentation reference requires it.
- Remove `_old_structure/` only after migration verification proves that current navigation and required historical retention are intact.
## Current transition state
Classified migration is active and the audience books now contain verified current pages. During this transition:
- The target directories listed above remain the placement contract; some planned chapters are not populated yet.
- `docs/_old_structure/` remains available for migration evidence but is not current documentation or command authority.
- `docs/fleet/`, `docs/native-kanban-sot/`, `docs/requirements/native-kanban-sot.md`, and the KBN-101 hold-site documents remain at their canonical paths because they are active executable or authority surfaces. Their placement cannot change through documentation-only cleanup.
- Root control and API artifacts remain until their authority and destination decisions are approved.
- `docs/SITEMAP.md` contains only resolvable current navigation plus a non-linked summary of authority-gated migration groups.
- No external publishing platform is assumed. The canonical source remains this repository under `docs/`.
## Current migration boundaries
- Do not bulk-promote or bulk-rewrite quarantined documentation; classify and verify each coherent slice.
- Do not rewrite product requirements, orchestrator-owned task state, mission status, or the API contract without the required authority decision.
- Do not create a documentation website or publishing pipeline as part of content migration.
- Do not treat Obsidian metadata as product or project source of truth.
+80 -74
View File
@@ -1,97 +1,103 @@
# Documentation Sitemap
> **Status:** Current navigation index. Quarantined and authority-gated material is summarized without being presented as current guidance.
## Compaction refresh lease broker
## Start here
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, constrained recovery, fail-closed posture, distinct-principal deployment, and residual risk.
- [Constrained recovery skill](../packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md) — source-resident thin wrapper, receipt scope, C4 replay boundary, and T-C middle-drop disclosure.
- [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements.
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
- [Documentation atlas](README.md) — placement, source-of-truth, linking, and migration rules.
- [User guide](USER-GUIDE/README.md) — end-user workflows and product behavior.
- [Administrator guide](ADMIN-GUIDE/README.md) — installation, configuration, operations, security, and recovery.
- [Developer guide](DEVELOPER-GUIDE/README.md) — architecture, testing, integrations, and contributor material.
- [API documentation](API/README.md) — API contract migration status.
- [Reports index](reports/README.md) — review, audit, QA, security, and retained evidence.
- [Archive index](archive/README.md) — superseded historical pages.
## CLI and skill management
## Product and delivery control
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
- [Product requirements](PRD.md) — normative requirements; currently marked draft and retaining authority-gated legacy references.
- [Active task rollup](TASKS.md) — orchestrator-owned work state; workers do not modify it.
- [MVP mission manifest](MISSION-MANIFEST.md) — control-plane mission rollup; activity and status remain under its authorized owner.
- [Documentation catalog and truth audit](reports/documentation/2026-08-10-docs-catalog-audit.md) — complete baseline inventory, evidence labels, broken-link clusters, and migration recommendations.
## Fleet configuration management
## Protected current authority and executable books
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
- [Desired, derived, and observed state](fleet/concepts/desired-vs-observed-state.md) — roster authority, generation, ownership, and drift.
- [Identity, class, and runtime](fleet/concepts/identity-class-runtime.md) — stable name, display alias, class, runtime, provider, and model separation.
- [Role authority and leases](fleet/concepts/role-authority-and-leases.md) — validator/merge-gate separation and bounded lease authority.
- [Generated launch chain](fleet/concepts/generated-env-launch-chain.md) — strict data parsing, precedence, and quarantine.
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — schema, supported values, required fields, defaults, and constraints.
- [Fleet CLI reference](fleet/reference/cli.md) — local desired-state commands, JSON/exit behavior, and gateway-catalog separation.
- [Lifecycle transitions](fleet/reference/lifecycle-transitions.md) — create/apply/reboot/migration/rollback boundaries.
- [Status and drift](fleet/reference/status-and-drift.md) — desired/managed/observed state and current/future classifications.
- [Safe agent CRUD](fleet/how-to/create-update-delete-agent.md) — expected generation, dry-run, and partial-failure recovery.
- [Local lifecycle operations](fleet/how-to/start-stop-restart.md) — persisted versus one-shot actions.
- [Configurable interaction instance](fleet/how-to/configure-tess-interaction.md) and [validator instance](fleet/how-to/configure-ultron-validator.md) — generic identities and protected limits.
- [Reconcile and recover](fleet/operations/reconcile-and-recover.md) — plan/apply lock and recovery behavior.
- [Environment quarantine](fleet/operations/env-quarantine.md) — private evidence and value-free diagnostics.
- [Systemd/tmux troubleshooting](fleet/operations/systemd-tmux-troubleshooting.md) — socket, holder, unmanaged-session, and lock decisions.
- [Backup/restore boundary](fleet/operations/backup-restore.md) and [upgrade-assets hold](fleet/operations/upgrade-assets.md).
- [v1-to-v2 migration preview](fleet/migration/v1-to-v2.md) and [executable artifact dispositions](fleet/migration/example-profile-disposition.md).
- [FCM M5 closure evidence](reports/documentation/758-fleet-config-ia-closure.md) and [approved deferrals](reports/deferred/758-fleet-config-deferrals.md).
These paths remain canonical because current source/tests consume them or because the KBN authority process protects them. Relocation requires an explicitly coordinated authority and consumer migration, not documentation-only cleanup.
## Official channel plugins
- [Fleet configuration management](fleet/README.md) — executable roster-v2 operator book, schema, examples, and north-star projections.
- [Fleet local canary](guides/fleet-local-canary.md) — protected Fleet validation procedure referenced by the current developer hold-site guide.
- [Native Kanban/SOT index](native-kanban-sot/INDEX.md) — active canonical KBN contract and workstream index.
- [Native Kanban/SOT requirements](requirements/native-kanban-sot.md) — active ratified requirements surface.
- [KBN-101 database role split](native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md) — active held database authority contract.
- [Developer hold-site guide](guides/dev-guide.md) — protected current KBN-101 development boundary.
- [Deployment hold-site guide](guides/deployment.md) — protected, non-operative PostgreSQL/deployment boundary.
- [Tier-migration hold-site guide](guides/migrate-tier.md) — protected secure migration route and hold boundary.
- [Federation setup hold site](federation/SETUP.md) — protected KBN-101 setup boundary; follow its explicit holds.
- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts.
- [Discord administrator configuration](guides/admin-guide.md#discord-ingress-security) — secrets, allowlists, bindings, role policy, and thread permissions.
- [Discord user workflow](tess/USER-GUIDE.md#discord-conversations) — in-channel messages, mention-created threads, and runtime-transparent continuity.
- [Channel plugin authoring](tess/PLUGIN-GUIDE.md#official-channel-adapter-contract) — requirements for future Matrix, Slack, and other official adapters.
- [Discord package guide](../plugins/discord/README.md) — package behavior, configuration shape, and development commands.
## User documentation
## Native Kanban and canonical task SOT
- [Quickstart](USER-GUIDE/getting-started/quickstart.md) — installed-CLI first-use route with local PGlite safety boundaries.
- [Web dashboard](USER-GUIDE/product/web-dashboard.md) — current routes, views, chat persistence, settings, and admin behavior.
- [Discord conversations](USER-GUIDE/workflows/discord-conversations.md) — current authorized parent-channel, thread, attachment, and control workflow.
- [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.
- [KBN-101 database role split](native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md) — rc.16 direct-Drizzle storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README/user-guide wrapper forms fail before masking and source-consistency rejects runner-delegation copy; held bootstrap → TLS/roles → run → verify → readiness; plus prior attestation, pgvector owner, classifier, TLS, activation, and certification prerequisite.
- [Federated tier data migration](guides/migrate-tier.md) — active KBN-101-07 operator route: runner-produced target attestation, dedicated non-DDL importer, and paired credential-/attestation-file references only.
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
- [KBN-101 exact-head security review](reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) — retained prior REQUEST CHANGES evidence for `da742ca`; rc.16 awaits independent exact-head re-review after closing the current generic storage-wrapper authority HIGH finding.
- [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.
## Administrator documentation
## Tess interaction agent
- [Administrator operations](ADMIN-GUIDE/operations/README.md) — current local procedures and explicitly held outlines.
- [Upgrade safety and recovery](ADMIN-GUIDE/operations/upgrade-safety-and-recovery.md) — installed-CLI/local-PGlite upgrade and framework recovery.
- [Mos connector lease operations](ADMIN-GUIDE/operations/mos-connector-lease-operations.md) — held/non-operative M1 outline while policy remains deny-all.
- [Administrator security](ADMIN-GUIDE/security/README.md) — current security chapter index.
- [SSO providers](ADMIN-GUIDE/security/sso-providers.md) — Authentik, WorkOS, and Keycloak configuration and discovery.
- [Discord ingress security](ADMIN-GUIDE/security/discord-ingress.md) — service authentication, allowlists, bindings, roles, replay, and failure controls.
### Operator guides
## Developer documentation
- [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 index](DEVELOPER-GUIDE/architecture/README.md) — current architecture contracts, decisions, and draft RFCs.
- [Channel protocol](DEVELOPER-GUIDE/architecture/channel-protocol.md) — shared DTOs and current Discord compatibility boundary; future adapters are draft.
- [Lease-broker protocol](DEVELOPER-GUIDE/architecture/lease-broker-protocol.md) — authenticated Unix-socket protocol and persistence boundary.
- [Lease-broker security](DEVELOPER-GUIDE/architecture/lease-broker-security.md) — identity, ancestry, filesystem, observer, and residual boundaries.
- [Whole mutator-class gate](DEVELOPER-GUIDE/architecture/mutator-class-gate.md) — default-deny tool authorization and launch choke point.
- [Compaction revocation](DEVELOPER-GUIDE/architecture/compaction-revocation.md) — lifecycle observers, generation fencing, and residual stale window.
- [Architecture decisions](DEVELOPER-GUIDE/architecture/decisions/README.md) — implemented and accepted boundaries.
- [Mos runtime portability M1](DEVELOPER-GUIDE/architecture/decisions/mos-runtime-portability-m1.md) — logical identity, connector lease, grants, audit, and fencing.
- [Architecture RFCs](DEVELOPER-GUIDE/architecture/rfcs/README.md) — draft proposals without operational authority.
- [Optional AI egress gateways](DEVELOPER-GUIDE/architecture/rfcs/optional-ai-egress-gateways.md) — draft proposal; LiteLLM and Bifrost are not integrated.
- [Lease-broker operations and verification](DEVELOPER-GUIDE/testing/lease-broker-operations.md) — safe static/test workflow; live procedures remain held.
- [Channel adapter authoring](DEVELOPER-GUIDE/integrations/channel-adapters.md) — current shared contract and Discord reference boundary.
### Architecture and security
## API transition
- [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 index](API/README.md) — consolidated gateway contract remains planned.
- [Legacy Tess-scoped OpenAPI contract](openapi-tess.yaml) — valid OpenAPI 3.1 artifact with incomplete gateway coverage; canonical scope requires maintainer approval.
### API contract
## Evidence and planning
- [Tess OpenAPI contract](openapi-tess.yaml)
- [Reports index](reports/README.md) — all tracked report categories and evidence boundaries.
- [Archived missions](archive/missions/README.md) — historical CLI, harness, install UX, and storage-abstraction delivery records.
- [Archived planning](archive/planning/README.md) — historical briefs, board reviews, and work-package specifications.
- [Archived work records](archive/work-records/README.md) — historical task scratchpads without live consumers.
- [P8-003 performance report](reports/qa/p8-003-performance-optimization.md) — historical implementation evidence, not a current SLO.
- [Plans index](plans/README.md) — approved intent and implementation/audit plans.
- [Documentation information-architecture design](plans/2026-08-10-docs-information-architecture-design.md) — approved documentation structure decision.
- [Documentation catalog-audit plan](plans/2026-08-10-docs-catalog-audit.md) — evidence method and migration acceptance criteria.
- [Scratchpads index](scratchpads/README.md) — working memory and verification records.
- [Documentation migration scratchpad](scratchpads/DOCS-IA-002-catalog-audit.md) — coordinator progress, verification, and resumability record.
### Migration and qualification
## Authority-gated migration backlog
- [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)
The complete file-level backlog remains in the [documentation catalog](reports/documentation/2026-08-10-docs-catalog-audit.md). The groups below are intentionally not linked as current pages:
## Runtime-neutral Mos portability
- **Fleet configuration:** the executable book is restored at `docs/fleet/`; any future audience-book relocation requires coordinated source/test and authority migration.
- **Federation:** the protected KBN-101 setup hold site remains canonical; disposition of the rest of the workstream requires maintainer/orchestrator confirmation.
- **Native Kanban/KBN-101:** active SSOT, requirements, and hold-site documents remain canonical; task state and future placement require product-owner, Task-18, and orchestrator decisions.
- **Tess:** mixed user, administrator, developer, migration, qualification, and API material requires audience splitting and a decision on active versus historical status.
- **Gateway API:** the Tess-scoped OpenAPI artifact must not be renamed into the canonical full-gateway contract until scope, authentication, errors, schemas, and transport coverage are approved.
- **Deployment and tier migration:** PostgreSQL, federated, bare-metal, Compose, Gateway/Web activation, and migration-runner procedures remain held under the repository safety policy.
- **Mixed legacy guides and scratchpads:** remaining records are coupled to control documents, tests/fixtures, mission evidence, or held procedures; migrate them with their owners.
- **Compaction-refresh probes:** scripts are Mos-gated and path-coupled; do not move or execute them as documentation cleanup.
- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies.
- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754)
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)
- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md)
- [M1 connector lease operations](guides/mos-connector-lease-operations.md)
`docs/_old_structure/` remains read-only migration quarantine. It is not current navigation and must not be used as command authority.
## Comms evolution — Matrix-native MACP (design, draft)
- [RFC-001 — MACP: a Mosaic-native, Matrix-native comms layer](rfcs/RFC-001-MACP-MATRIX-NATIVE.md) — Synapse + Mosaic appservice backbone, MACP v1 protocol, presence/escalation, federation, strangler migration off the Hermes MCP bridge.
- [RFC-002 — Install, configuration & topology for the Matrix/MACP comms system](rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md) — open-source install topology modes, ACME cert provisioning, pluggable secret backend, and config precedence.
+111
View File
@@ -0,0 +1,111 @@
# SSO Providers
Mosaic Stack supports optional enterprise single sign-on through Better Auth's generic OAuth flow. The gateway mounts Better Auth under `/api/auth`, so every provider callback terminates at:
```text
{BETTER_AUTH_URL}/api/auth/oauth2/callback/{providerId}
```
For the providers in this document:
- Authentik: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/authentik`
- WorkOS: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos`
- Keycloak: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak`
## Required environment variables
### Authentik
```bash
AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic
AUTHENTIK_CLIENT_ID=...
AUTHENTIK_CLIENT_SECRET=...
```
### WorkOS
```bash
WORKOS_ISSUER=https://your-company.authkit.app
WORKOS_CLIENT_ID=client_...
WORKOS_CLIENT_SECRET=...
NEXT_PUBLIC_WORKOS_ENABLED=true
```
`WORKOS_ISSUER` should be the WorkOS AuthKit issuer or custom auth domain, not the raw REST API hostname. Mosaic derives the OIDC discovery URL from that issuer.
### Keycloak
```bash
KEYCLOAK_ISSUER=https://auth.example.com/realms/master
KEYCLOAK_CLIENT_ID=mosaic
KEYCLOAK_CLIENT_SECRET=...
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
```
If you prefer, you can keep the issuer split as:
```bash
KEYCLOAK_URL=https://auth.example.com
KEYCLOAK_REALM=master
```
The auth package will derive `KEYCLOAK_ISSUER` from those two values.
## WorkOS setup
1. In WorkOS, create or select the application that will back Mosaic login.
2. Configure an AuthKit domain or custom authentication domain for the application.
3. Add the redirect URI:
```text
{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos
```
4. Copy the application's `client_id` and `client_secret` into `WORKOS_CLIENT_ID` and `WORKOS_CLIENT_SECRET`.
5. Set `WORKOS_ISSUER` to the AuthKit domain from step 2.
6. Create the WorkOS organization and attach the enterprise SSO connection you want Mosaic to use.
7. Set `NEXT_PUBLIC_WORKOS_ENABLED=true` in the web deployment so the login button is rendered.
## Keycloak setup
1. Start from an existing Keycloak realm or create a dedicated realm for Mosaic.
2. Create a confidential OIDC client named `mosaic` or your preferred client ID.
3. Set the valid redirect URI to:
```text
{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak
```
4. Set the web origin to the public Mosaic web URL.
5. Copy the client secret into `KEYCLOAK_CLIENT_SECRET`.
6. Set either `KEYCLOAK_ISSUER` directly or `KEYCLOAK_URL` + `KEYCLOAK_REALM`.
7. Set `NEXT_PUBLIC_KEYCLOAK_ENABLED=true` in the web deployment so the login button is rendered.
### Local Keycloak smoke test
If you want to test locally with Docker:
```bash
docker run --rm --name mosaic-keycloak \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.1 start-dev
```
Then configure:
```bash
KEYCLOAK_ISSUER=http://localhost:8080/realms/master
KEYCLOAK_CLIENT_ID=mosaic
KEYCLOAK_CLIENT_SECRET=...
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
```
## Web flow
The web login page renders provider buttons from `NEXT_PUBLIC_*_ENABLED` flags. Each button links to `/auth/provider/{providerId}`, and that page initiates Better Auth's `signIn.oauth2` flow before handing off to the provider.
## Failure mode
Provider config is optional, but partial config is rejected at startup. If any provider-specific env var is present without the full required set, `@mosaicstack/auth` throws a bootstrap error with the missing keys instead of silently registering a broken provider.
-49
View File
@@ -1,49 +0,0 @@
# User Guide
> **Status:** Partially migrated. The quickstart, web-dashboard reference, and Discord conversation workflow are current.
This book is the canonical home for end-user workflows, user-visible behavior, product concepts, and user troubleshooting. Keep installation, deployment, security controls, and recovery procedures in [`ADMIN-GUIDE/`](../ADMIN-GUIDE/); keep implementation detail in [`DEVELOPER-GUIDE/`](../DEVELOPER-GUIDE/).
## Start here
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
- [Documentation sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary.
- [Quickstart](getting-started/quickstart.md) — install Mosaic, complete setup, and launch a session.
- [Web dashboard](product/web-dashboard.md) — current routes, navigation, chat persistence, projects/tasks views, settings, and admin behavior.
- [Discord conversations](workflows/discord-conversations.md) — current authorized parent-channel, thread, attachment, and control workflow.
## Chapter map
| Chapter | Scope | Status |
| ------------------ | ------------------------------------------------------------- | ---------------------------------------------------- |
| `getting-started/` | First-use setup, orientation, and quickstarts. | Quickstart is current; additional pages are planned. |
| `concepts/` | User-facing terminology, product concepts, and mental models. | Scaffold only. |
| `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord conversation workflow is current. |
| `product/` | Current product surfaces and visible behavior. | Web dashboard reference is current. |
| `troubleshooting/` | User-visible failures, diagnostics, and fixes. | Scaffold only. |
### Current pages
- [Quickstart](getting-started/quickstart.md) — the verified installed-CLI first-use path.
- [Web dashboard](product/web-dashboard.md) — verified current Next.js dashboard behavior and limitations.
- [Discord conversations](workflows/discord-conversations.md) — verified current Discord user workflow.
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
## Migration backlog — not current navigation
These are source candidates, not current user guidance:
- `_old_structure/guides/user-guide.md` — quarantined historical source; verify every claim before promotion. See the [documentation catalog](../reports/documentation/2026-08-10-docs-catalog-audit.md) for its disposition.
- The former root `QUICKSTART.md` was an empty placeholder and has been replaced by the current page above.
Do not link to the quarantine as a current user path. Create a new page only after classifying its audience, status, and evidence in the migration report.
## Authoring boundary
New user-facing documentation belongs under one of the chapter directories above. Use a lowercase kebab-case page name, state whether commands are current or held, and link back to this index plus related canonical sources.
## Related
- [[README|Documentation contract]]
- [[PRD|Product requirements]]
@@ -1,129 +0,0 @@
---
title: Mosaic Stack Quickstart
type: guide
audience: user
status: current
source_of_truth: false
---
# Mosaic Stack Quickstart
Verify and install the versioned Mosaic CLI package, complete first-run setup, connect to a gateway, and launch an agent session. This page covers the installed-CLI path with the default local storage tier.
> **Scope:** This is an end-user installation route. It does not authorize PostgreSQL setup, production deployment, or starting Gateway/Web directly from a source checkout. Use the [administrator guide](../../ADMIN-GUIDE/README.md) for deployment and the [developer guide](../../DEVELOPER-GUIDE/README.md) for contributor setup.
## Requirements
- Node.js 20 or newer.
- npm, for the global Mosaic CLI installation.
- At least one supported agent runtime:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://opencode.ai)
- [Pi](https://pi.dev)
- Credentials for the runtime or model provider you plan to use.
## 1. Install Mosaic
> **Installation hold:** Do not execute the website installer or a script fetched from a mutable repository branch. The current release tooling does not publish an independently verified immutable dependency closure or a signed installer. If your policy requires either property, stop until a release provides it.
The currently published CLI/framework package is `@mosaicstack/[email protected]`. Pin the exact package version and verify its published artifact integrity before installation:
```bash
registry='https://git.mosaicstack.dev/api/packages/mosaicstack/npm/'
package='@mosaicstack/mosaic@0.0.49'
expected_integrity='sha512-/Zsjdf8Ln2QchQTG9lirpqSxhDbNyBjOvGkWrDWRugxCuqUWP5V0rUNVDivmPvro+Vyq3hxDyA9i4hDfkEFmMg=='
actual_integrity="$(npm view --registry="$registry" "$package" dist.integrity)"
test "$actual_integrity" = "$expected_integrity"
npm install --global --registry="$registry" "$package"
```
The explicit comparison pins the reviewed top-level package artifact; npm also checks the downloaded tarball against registry integrity metadata. It does **not** make the package's transitive dependency graph independently immutable. Review the [package release](https://git.mosaicstack.dev/mosaicstack/-/packages/npm/%40mosaicstack%2Fmosaic/0.0.49) before proceeding, and stop if the integrity comparison fails.
The versioned package includes the Mosaic framework and CLI. npm installs it under your configured global prefix. Ensure that prefix's `bin` directory is on `PATH` if your shell cannot find `mosaic`.
## 2. Complete first-run setup
The versioned package install does not launch the wizard. Run it manually:
```bash
mosaic wizard
```
The wizard guides framework setup and gateway installation. It can collect your agent identity, preferences, provider configuration, and gateway administrator details interactively.
For a separately installed or existing gateway, skip local gateway installation and use its URL in the login step below.
## 3. Verify and sign in
For a gateway installed on this machine, check its health and setup state:
```bash
mosaic gateway status
mosaic gateway verify
```
Sign in without putting your password in shell history or process listings:
```bash
mosaic gateway login
```
The command prompts for the gateway URL, email, and password as needed. Do not pass passwords with `--password`.
For a remote gateway, provide its URL explicitly:
```bash
mosaic gateway login --gateway https://gateway.example.com
```
## 4. Launch Mosaic
Open the interactive terminal interface:
```bash
mosaic tui
```
The TUI defaults to `http://localhost:14242` and can prompt for login if no valid session is saved. To connect it to another gateway:
```bash
mosaic tui --gateway https://gateway.example.com
```
You can also launch a supported runtime through Mosaic:
```bash
mosaic pi
mosaic claude
mosaic codex
mosaic opencode
```
Use the launcher matching the runtime you installed and authenticated.
## 5. Inspect configuration and health
These commands are safe diagnostics and do not change the product requirements or active task ledger:
```bash
mosaic config show
mosaic doctor
mosaic gateway logs
```
If the gateway is unhealthy, run `mosaic gateway status` and `mosaic gateway logs` before attempting a reinstall. If your session expires, run `mosaic gateway login` again.
## Storage and deployment boundary
The default local gateway tier uses embedded PGlite and does not require an external PostgreSQL or Valkey service. This quickstart intentionally does not configure `DATABASE_URL`, PostgreSQL, pgvector, or a federated deployment.
For standalone or federated storage, deployment topology, secrets, SSO, backups, or recovery, stop here and use the [administrator guide](../../ADMIN-GUIDE/README.md). For work from a repository checkout, keep `DATABASE_URL` unset and follow the [developer guide](../../DEVELOPER-GUIDE/README.md); do not use root `pnpm dev` as a local PGlite route while the current dotenv safety hold remains active.
## Related
- [User Guide](../README.md)
- [Documentation atlas](../../README.md)
- [Administrator Guide](../../ADMIN-GUIDE/README.md)
- [Developer Guide](../../DEVELOPER-GUIDE/README.md)
- [Repository README](../../../README.md)
-276
View File
@@ -1,276 +0,0 @@
---
title: Mosaic web dashboard
type: guide
audience: user
status: current
source_of_truth: false
---
# Mosaic Web Dashboard
This page documents the current Next.js dashboard: its routes, navigation, visible
views, and the chat persistence behavior supported by the checked-in web and gateway
implementation.
> **Current UI boundary:** Projects and tasks can be displayed in the dashboard, but
> the current dashboard does not provide **New Project** or **New Task** controls.
> Those entities can be created through authenticated gateway API clients; that API
> surface is separate from the views described here.
## Access and routes
The dashboard uses the gateway session. Dashboard routes are protected by the web
`AuthGuard`; the admin route also requires the `admin` role.
| Path | Access | Current behavior |
| --------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | Any | Redirects to `/chat`. A signed-out visitor is then redirected to `/login`. |
| `/login` | Signed out | Email/password sign-in, plus buttons for configured SSO providers. Successful sign-in returns to `/chat`. |
| `/register` | Signed out | Creates an account with name, email, and password, then returns to `/chat`. |
| `/auth/provider/[provider]` | SSO handoff | Looks up the requested provider and starts an OIDC sign-in when that provider is configured and supports OIDC. Unknown, disabled, or incompatible providers show an error and a link back to login. |
| `/chat` | Signed in | Conversation list and streamed assistant chat. |
| `/projects` | Signed in | Project cards and the Active Mission status section. |
| `/projects/[id]` | Signed in | Project detail view with overview, tasks, missions, and an optional read-only PRD tab. |
| `/tasks` | Signed in | Task data in Kanban or list view. |
| `/settings` | Signed in | Profile, appearance, notifications, and provider tabs. |
| `/admin` | Signed-in admin | User Management and System Health tabs. Non-admin users are redirected away from the admin page. |
Settings and admin tabs are client-side tabs; changing a tab does not change the URL.
## Dashboard navigation
The **Workspace** sidebar contains these links, in order:
1. **Chat**`/chat`
2. **Tasks**`/tasks`
3. **Projects**`/projects`
4. **Settings**`/settings`
5. **Admin**`/admin`
The active link is highlighted, including the parent link when viewing a project
such as `/projects/<id>`. The Admin link is rendered in the shared sidebar, but the
page itself is still restricted to administrators.
The top bar provides:
- a sidebar toggle (collapse/expand on desktop; open/close an overlay on mobile),
- the light/dark theme toggle,
- the signed-in user's name, and
- **Sign out**, which returns to `/login`.
The applied global theme is stored in browser local storage under `mosaic-theme`.
## Chat
### Starting and managing conversations
Open `/chat` and either select a conversation or choose **Start new conversation**
in the empty state. The conversation sidebar also has **New conversation**. Creating
one calls the gateway and gives the conversation a server-side ID before the first
message is sent.
The sidebar currently supports:
- searching conversation titles,
- selecting a conversation,
- renaming a conversation inline (Enter or leaving the field commits the new title),
- deleting a conversation after confirmation, and
- grouping conversations by project when project data is available.
Archived conversations are filtered out of the sidebar. The current dashboard does
not expose archive/restore controls.
If no conversation is selected when a message is sent, the dashboard creates one
automatically. A new conversation is titled from the first 60 characters of the
first message; an empty placeholder conversation is similarly retitled after its
first message. The active conversation is held in page state, not in the URL: the
route remains `/chat` rather than changing to `/chat/<id>`. After a refresh, select
the stored conversation again from the sidebar.
### Sending and streaming
The chat composer provides:
- a model selector populated from available gateway providers and models,
- a multiline message field,
- character and approximate token counts, and
- a **Send** button.
Use **Cmd/Ctrl+Enter** to send. The composer is disabled while a response is
streaming. The interface displays a **Stop** control during streaming, but the
current `ChatPage` does not pass a stop handler, so cancellation is not a reliable
current dashboard action.
Assistant and user messages render Markdown. Assistant messages can show model and
token metadata when it is present in the returned message, and rendered user/assistant
messages have a copy control.
### What is persisted
Assistant replies are not page-only or memory-only. The current flow is:
1. The browser creates or selects a conversation and optimistically displays the
user's message.
2. The browser submits the user message to the gateway conversation-message API and
sends the turn over the authenticated `/chat` WebSocket namespace.
3. The gateway streams the assistant response to the page. At `agent_end`, it saves
non-empty assistant text to the conversation with model, provider, tool-call, and
token-usage metadata when available.
4. Selecting the conversation later loads `/api/conversations/<id>/messages`, so
stored user and assistant messages are shown again. When the gateway has to create
or resume the agent session for that conversation, it also loads stored conversation
history as context.
The live page appends the completed response as soon as the stream ends; the gateway
persistence write is asynchronous. Therefore a persistence error can leave a reply
visible in the current page while it is unavailable after a later reload. The gateway
redacts sensitive content before emitting and storing assistant text.
## Projects and missions
### Project list: `/projects`
The project page loads the signed-in user's projects and shows either:
- project cards with name, status, description, and creation date, or
- **No projects yet** with the message that projects appear when created through the
gateway API.
The page also shows **Active Mission**. It reports the mission ID, phase, task
completion count, and status when coordination data is available; otherwise it
shows **No active mission detected**.
There is no New Project button or project form on this page. The gateway has
project CRUD endpoints, but this dashboard page currently reads project data only.
### Project detail: `/projects/<id>`
A project detail page shows its name, status, description, created/updated dates,
and task summary counts for total, done, in progress, and blocked tasks. Its tabs
are:
- **Overview** — up to five recently updated tasks, a mission summary, and non-empty
project metadata.
- **Tasks (`n`)** — task status filters and clickable task rows.
- **Missions (`n`)** — a status-ordered mission timeline.
- **PRD** — present only when project metadata contains non-empty `prd` or
`prdContent` text; the content is displayed read-only.
Selecting a task from the project detail Tasks tab opens a detail dialog with its
status, priority, description, assignee, due date, timestamps, tags, pull-request
links, and notes when those fields exist. The dialog can be closed with its close
button, the backdrop, or Escape. The dashboard does not provide project or task
edit controls in this view.
## Tasks
Open `/tasks` to load the tasks visible to the signed-in user. The default view is
**Kanban**; a toggle switches to **List**.
- Kanban columns are **Not Started**, **In Progress**, **Blocked**, and **Done**.
- Kanban cards show title, priority, optional description, status, and due date.
- List view shows title, status, priority, and due date.
- The supported task status set also includes `cancelled`; it is not a Kanban
column, but a returned cancelled task can appear in List view.
The top-level Tasks page has no New Task button, form, or working task edit dialog.
Clicking a task in that page does not open the project-detail dialog. The gateway
supports authenticated task CRUD separately, while this dashboard page currently
reads and presents task data.
## Settings
Open `/settings`. The page has four tabs and opens on **Profile**.
### Profile
- **Display Name** can be edited.
- **Email** is displayed but disabled; the page says it cannot be changed there.
- **Avatar URL** can be edited.
- **Save changes** sends the profile update through BetterAuth and reports saving,
saved, or an error state.
### Appearance
The tab presents **System**, **Light**, and **Dark** theme choices, a **Collapse
sidebar by default** switch, and a **Default Model** text field. **Save changes**
saves these as user preferences.
Current implementation limits are worth noting: the shared sidebar provider starts
expanded and does not read `ui.sidebar_collapsed` on page load; the global applied
theme is controlled by the top-bar theme toggle; and the chat page initially selects
the first available provider model rather than reading `ui.default_model` itself.
Do not treat these preference fields as proof that those defaults are applied across
all dashboard sessions.
### Notifications
The tab presents and saves three email preferences:
- **Agent task completed** — initially off,
- **Mentions** — initially on, and
- **Weekly digest** — initially off.
### Providers
The Providers tab contains SSO discovery and LLM provider discovery/testing areas:
- **SSO Providers** shows configured providers and their protocols, callback path,
team-sync claim, SAML fallback, and warnings when supplied by the gateway. It does
not configure SSO from the dashboard.
- **LLM Providers** shows configured providers as Active or Inactive. A provider can
be tested for reachability; the result may include latency, an error, and the
number of discovered models. Expanding a provider shows model capabilities,
context size, cost, and the default-model marker.
When no LLM providers are configured, the page displays setup guidance mentioning
`OLLAMA_BASE_URL` and `MOSAIC_CUSTOM_PROVIDERS`. Provider credentials and provider
configuration are not editable in this dashboard tab.
## Admin panel
The `/admin` page is wrapped in an admin-role guard. It opens on **User Management**
and provides **System Health** as the second tab.
### User Management
The page loads the user list and provides:
- a user count,
- **+ New User**, with name, email, password, and `member`/`admin` role fields,
- role promotion/demotion,
- ban/unban,
- deletion after confirmation, and
- a retry action when loading fails.
The table shows name/email, role, active or banned status, creation date, and
available actions.
### System Health
The health tab loads the gateway's overall `ok` or `degraded` status and provides a
**Refresh** action. Its cards cover:
- PostgreSQL database status and latency/error,
- Valkey cache status and latency/error,
- active agent-session count, and
- configured LLM providers and model counts.
## Evidence used for this page
The behavior above was checked against the current implementation and focused tests,
not copied forward as-is from the historical mixed guide. The main evidence files
are:
- `apps/web/src/app/(dashboard)/` route pages and `apps/web/src/app/page.tsx`,
- `apps/web/src/components/layout/`, `apps/web/src/components/chat/`,
`apps/web/src/components/projects/`, and `apps/web/src/components/tasks/`,
- `apps/web/e2e/navigation.spec.ts`, `chat.spec.ts`, `projects.spec.ts`,
`settings.spec.ts`, and `admin.spec.ts`,
- `apps/gateway/src/chat/chat.gateway.ts` and
`apps/gateway/src/__tests__/conversation-persistence.test.ts`,
- `apps/gateway/src/chat/chat.gateway-redaction.spec.ts`, and
- the authenticated gateway controllers under `apps/gateway/src/conversations/`,
`projects/`, `tasks/`, and `admin/`.
Related: [User Guide index](../README.md) and [SSO provider runbook](../../ADMIN-GUIDE/security/sso-providers.md).
@@ -1,128 +0,0 @@
# Discord conversations
> **Status:** Current Discord workflow for an administrator-provisioned, authorized guild channel.
>
> Telegram shared-contract parity, Matrix channel conversations, and a gateway-wide shared adapter registry are not current features. See [Current versus planned](#current-versus-planned) before using any older channel instructions.
>
> **Audience:** People conversing with an agent through Discord.
This workflow assumes an administrator has configured the Discord bot, gateway connection, allowlists, and a logical-agent binding. Users cannot create a binding or authorize themselves from Discord.
## Current versus planned
| Surface | Status | What you can rely on |
| ----------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Discord guild messages | **Current live routing** | Authorized messages route to the configured logical agent; parent/thread delivery is tested, but ordinary durable history is not guaranteed. |
| Telegram | **Not shared-contract parity** | A raw legacy plugin exists, but its current source has no equivalent documented authorization, pairing, route, or focused package tests. |
| Matrix | **Not implemented as a channel workflow** | No current gateway channel adapter and test boundary establishes a Matrix conversation workflow. |
| Shared channel registry | **Not implemented** | The gateway's current registry hosts lifecycle wrappers; it does not provide universal channel routing or health. |
## Start in a configured parent channel
Send a normal message in the administrator-configured parent text channel. You do **not** need to mention the bot for an ordinary turn.
For an authorized user, Mosaic:
1. checks the guild, parent channel, user allowlist, pairing, role, and rate limit;
2. keeps the response target in the parent channel; and
3. routes the turn to the binding's logical agent using a stable conversation address.
No Discord thread is created for this untagged parent-channel case. The response is sent back to that same channel.
Messages from an unconfigured guild/channel, an unallowlisted user, an unpaired user, or a user without a role that can send are ignored without creating a thread or dispatching to the gateway. Bot-authored messages are ignored. Direct messages are not handled by the current guild ingress path.
## Start a threaded topic with a mention
Mention the bot in a parent channel when you want a separate topic:
```text
@Mosaic investigate the deployment failure
```
The current Discord adapter creates a public thread for the message, removes the bot mention from the content sent to the agent, and targets the response to that thread. If the message already has a Discord thread attached, the adapter reuses it instead of creating another one.
Authorization happens before thread creation. If the user, guild, parent channel, pairing, role, or rate check fails, no thread is created. If Discord cannot create or fetch the requested thread, the message is not dispatched because Mosaic cannot guarantee the requested response destination.
## Continue inside a thread
Reply in the existing authorized thread without mentioning the bot again. The adapter:
- authorizes the message against the configured parent text channel;
- keeps the thread as the response target; and
- never attempts to create a nested thread.
A category above the text channel is not used as the authorization parent. Only the actual configured text-channel parent grants thread inheritance.
The stable conversation address is formed from the configured logical agent, channel name, and response channel/thread, for example:
```text
<logical-agent-id>:discord:<response-channel-id>
```
It does not contain Claude, Codex, Pi, OpenCode, a model, a provider, a process, or a native runtime-session ID. The gateway owns runtime selection behind that route, so changing the runtime/provider does not require a new Discord address.
### Durability limitation
Treat the current Discord path as **live routing and delivery**, not guaranteed durable conversation history. The Discord conversation address above is an external route string, while persisted conversation/message rows use UUID conversation IDs. No current external-route-to-UUID mapping was found. The gateway can continue dispatching after a persistence/binding failure, so a reply may appear in Discord without durable history or restart/resume continuity.
Do not rely on Discord as the sole record of a conversation. Durable history requires an implementation that maps the external route to a UUID, surfaces persistence failure, and proves fresh-message persistence and restart recovery in an integration test.
## Attachments
An authorized message may contain text, attachments, or an attachment without text. The current adapter maps attachments into the shared message shape and preserves the native attachment ID, name, URL, content type, and optional size.
The gateway accepts only bounded attachment metadata: at most 10 attachments, HTTPS URLs without credentials, query strings, or fragments, and bounded ID, name, URL, MIME-type, size, and total metadata values. An unsafe or malformed attachment is rejected before the message is acknowledged or dispatched. Binary content is not embedded in the gateway message; the attachment remains a validated external reference.
## Runtime controls
The current Discord text controls are:
```text
/approve
/stop <approval>
```
They remain on the current parent/thread route and do not create a new topic. Approval and stop require an already enrolled durable session; ordinary Discord chat does not prove that enrollment occurred. These are privileged operations: the paired user must have the `admin` role and a provisioned `mosaicUserId`, the gateway must have a tenant configured for the control path, and the enrolled durable session must still belong to the bound logical agent. A stop must present the exact approval reference created for that target; approval consumption is one-time.
If these checks fail, the control operation is denied or produces no successful control result. Do not assume that being able to read a channel grants control authority.
## Response and delivery behavior
The gateway emits raw stream events to the current Discord compatibility path. The plugin buffers `agent:start`/`agent:text` output and sends the completed response on `agent:end`; this is not a claim of token-by-token Discord rendering.
Outbound Discord text is split at a 1,900-character boundary. Transient rate-limit, server, and network failures are retried up to three attempts with one deterministic nonce per correlation/chunk; permanent delivery failures are not retried. A response route is checked against the configured logical-agent/channel binding before Discord is contacted.
## If a message gets no response
Check these possibilities with the administrator:
1. You are in a direct message, an unconfigured guild/channel, or a thread whose parent is not configured.
2. Your Discord user ID is missing from the user allowlist or `pairedUsers`.
3. Your pairing is `viewer`, which cannot send ordinary agent turns.
4. The per-user/channel message or mention-thread limit was reached.
5. The bot is not connected to Discord or the gateway Socket.IO `/chat` namespace.
6. A mentioned topic could not create/fetch its thread.
7. The gateway rejected the signed envelope, route, attachment, or replayed native message ID.
8. `/approve` or `/stop <approval>` was attempted without the required admin pairing, tenant, pre-enrolled durable session, or exact approval.
These failures are intentionally fail-closed; an unauthorized or unverifiable message should not create a thread or agent side effect.
## Not current: Telegram and Matrix
Do not substitute Telegram or Matrix instructions for this workflow:
- The current Telegram plugin uses raw Telegraf and Socket.IO messages, maps a chat to `telegram-<chatId>`, accepts text only, and does not establish the Discord-style service-token, allowlist, pairing, shared-route, or attachment boundary.
- No current Matrix gateway channel adapter, channel binding, user authorization flow, or focused channel tests establish a Matrix conversation workflow.
- The current gateway plugin list is lifecycle-only; it is not proof that every channel shares this Discord behavior.
Those are parity/design gaps, not alternate user workflows.
## Evidence and related pages
- [Channel protocol architecture](../../DEVELOPER-GUIDE/architecture/channel-protocol.md) — current shared types, Discord compatibility path, and explicit parity boundary.
- [Discord ingress security](../../ADMIN-GUIDE/security/discord-ingress.md) — administrator configuration, authentication, authorization, and failure controls.
- [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) — native Discord routing and delivery implementation.
- [`plugins/discord/src/index.test.ts`](../../../plugins/discord/src/index.test.ts) — parent, mention, existing-thread, authorization, attachment, rate, egress, and health tests.
- [`apps/gateway/src/plugin/discord-ingress.security.spec.ts`](../../../apps/gateway/src/plugin/discord-ingress.security.spec.ts) — gateway signature, replay, binding, attachment, approval, and stop tests.
- [`apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts`](../../../apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts) — Discord control-flow test with explicit durable-session pre-enrollment; it is not fresh-message persistence evidence.
- [User Guide](../README.md)
@@ -0,0 +1,151 @@
# 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.
+751
View File
@@ -0,0 +1,751 @@
# Channel Protocol Architecture
**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative
**Authors:** Mosaic Core Team
**Last Updated:** 2026-07-14
**Covers:** M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix)
---
## Overview
The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels).
The implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups:
1. `OfficialChannelAdapter` — transport lifecycle and connection health.
2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data.
3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address.
4. `ChannelResponseTargetDto` — channel/thread destination for replies.
All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter.
---
## M7-001: OfficialChannelAdapter Interface
```typescript
interface OfficialChannelAdapter {
/** Stable, lowercase adapter identifier such as "discord" or "matrix". */
readonly name: string;
/** Establish both native-channel and gateway connections. */
start(): Promise<void>;
/** Gracefully close connections and release resources. */
stop(): Promise<void>;
/** Best-effort health; ordinary disconnection is a result, not an exception. */
health(): Promise<{
status: 'connected' | 'degraded' | 'disconnected';
detail?: string;
}>;
}
```
The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned.
### Stable conversation route
```typescript
interface ChannelConversationRouteDto {
bindingId: string;
logicalAgentId: string;
conversationId: string;
channelName: string;
authorizationChannelId: string;
responseTarget: { channelId: string; threadId?: string };
}
```
Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target.
### Typed ingress and egress ports
`ChannelIngressPort` is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same `ChannelIngressDto` before signing. The adapter uses a supplied `ChannelIngressPort` directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core.
`ChannelBindingDto` contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits `ChannelIngressDto` to `ChannelIngressPort.receive()`. It includes the normalized message, `ChannelAuthorizedPrincipalDto`, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port.
Gateway policy and runtime routing produce `ChannelEgressDto`, which `ChannelEgressPort.send()` delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics.
### Adapter Registration
Adapters are registered with the gateway plugin host at startup. The host calls `start()`/`stop()` and may monitor `health()` on a configurable interval. A richer dynamic `ChannelRegistry` remains a compatible future extension of this lifecycle contract.
```
ChannelRegistry
└── register(adapter: OfficialChannelAdapter): void
└── getAdapter(name: string): OfficialChannelAdapter | null
└── listAdapters(): OfficialChannelAdapter[]
└── healthAll(): Promise<Record<string, AdapterHealth>>
```
---
## M7-002: ChannelMessageDto Protocol
### Canonical Message Format
```typescript
interface ChannelMessageDto {
/**
* Globally unique message ID.
* Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
* when sending. Channel-native IDs are stored in metadata.channelMessageId.
*/
id: string;
/**
* Channel-native room/conversation/channel identifier.
* The adapter populates this from the inbound message.
* For outbound messages, the caller supplies the target channel.
*/
channelName: string;
channelId: string;
/**
* Channel-native identifier of the message sender.
* For Mosaic-originated messages this is the Mosaic userId or agentId.
*/
senderId: string;
/** Sender classification. */
senderKind: 'user' | 'agent' | 'system';
/**
* Textual content of the message.
* For non-text content types (image, file) this may be an empty string
* or an alt-text description; the actual payload is in `attachments`.
*/
content: string;
/**
* Hint for how `content` should be interpreted and rendered.
* - "text" — plain text, no special rendering
* - "markdown" — CommonMark markdown
* - "code" — code block (use metadata.language for the language tag)
* - "image" — binary image; content is empty, see attachments
* - "file" — binary file; content is empty, see attachments
*/
contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file';
/**
* Arbitrary key-value metadata for channel-specific extension fields.
* Examples: { channelMessageId, language, reactionEmoji, channelType }.
* Adapters should store channel-native IDs here so round-trip correlation
* is possible without altering the canonical fields.
*/
metadata: Readonly<Record<string, ChannelMetadataValue>>;
/**
* Optional thread or reply-chain identifier.
* For threaded channels (Matrix, Discord threads, Telegram topics) this
* groups messages into a logical thread scoped to the same channelId.
*/
threadId?: string;
/**
* The canonical message ID this message is a reply to.
* Maps to channel-native reply/quote mechanisms in each adapter.
*/
replyToId?: string;
/**
* Binary or URI-referenced attachments.
* Each attachment carries its MIME type and a URL or base64 payload.
*/
attachments?: readonly ChannelAttachmentDto[];
/** ISO-8601 wall-clock timestamp when the message was sent/received. */
timestamp: string;
}
interface ChannelAttachmentDto {
/** Channel-native attachment identifier. */
id: string;
/** Filename or display name. */
name: string;
/** MIME type when supplied by the channel. */
mimeType: string | null;
/**
* URL pointing to the attachment, OR a `data:` URI with base64 payload.
* Adapters that receive file uploads SHOULD store to object storage and
* populate a stable URL here rather than embedding the raw bytes.
*/
url: string;
/** Size in bytes, if known. */
sizeBytes?: number;
}
```
---
## Channel Translation Reference
The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`.
### Matrix
| ChannelMessageDto field | Matrix equivalent |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
| `channelId` | Matrix room ID (`!roomid:homeserver`) |
| `senderId` | Matrix user ID (`@user:homeserver`) |
| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
| `content` | `event.content.body` |
| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` |
| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` |
| `attachments` | Populated from `url` in `m.image` / `m.file` events |
| `timestamp` | `new Date(event.origin_server_ts)` |
| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` |
**Outbound:** Adapter sends `m.room.message` with `msgtype = m.text` (or `m.notice` for system messages). Markdown content is sent with `format = org.matrix.custom.html` and a rendered HTML body.
---
### Discord
| ChannelMessageDto field | Discord equivalent |
| ----------------------- | ----------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
| `channelId` | Discord channel ID (snowflake string) |
| `senderId` | Discord user ID (snowflake) |
| `senderKind` | `"user"` for human members; `"agent"` for bot messages |
| `content` | `message.content` |
| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) |
| `threadId` | `message.thread.id` when the message is inside a thread channel |
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
| `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` |
| `timestamp` | `new Date(message.timestamp)` |
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` |
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentKind = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
### Discord routing and thread policy
A configured Discord binding maps `(guildId, parentChannelId)` to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID.
| Inbound location/trigger | Conversation and response target |
| ------------------------------------------ | --------------------------------------------------------------- |
| Authorized untagged parent-channel message | Parent channel; response is sent in-channel |
| Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread |
| Authorized message already in a thread | Existing thread; no repeated mention and no nested thread |
| `/approve` or `/stop <approval>` | Current parent/thread durable session; no new topic is created |
Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target.
### Discord service ingress security
The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads.
---
### Telegram
| ChannelMessageDto field | Telegram equivalent |
| ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
| `channelId` | Telegram `chat_id` (integer as string) |
| `senderId` | Telegram `from.id` (integer as string) |
| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages |
| `content` | `message.text` or `message.caption` |
| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
| `threadId` | `message.message_thread_id` (for supergroup topics) |
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` |
| `timestamp` | `new Date(message.date * 1000)` |
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` |
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
---
### TUI (Terminal UI)
The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel.
| ChannelMessageDto field | TUI equivalent |
| ----------------------- | ------------------------------------------------------------------ |
| `id` | Generated UUID (TUI has no native message IDs) |
| `channelId` | `"tui:<conversationId>"` — the active conversation ID |
| `senderId` | Authenticated Mosaic `userId` |
| `senderKind` | `"user"` for human input; `"agent"` for agent replies |
| `content` | Raw text from stdin / agent output |
| `contentKind` | `"text"` for input; `"markdown"` for agent responses |
| `threadId` | Not used (TUI sessions are linear) |
| `replyToId` | Not used |
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
| `timestamp` | `new Date()` at the moment of send |
| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` |
**Outbound:** The adapter writes rendered content to stdout. Markdown is rendered via a terminal markdown renderer (e.g. `marked-terminal`). Code blocks are syntax-highlighted when `metadata.colorSupport = true`.
---
### WebUI
The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`).
| ChannelMessageDto field | WebUI equivalent |
| ----------------------- | ------------------------------------------------------------ |
| `id` | Generated UUID; echoed back in the WebSocket event |
| `channelId` | `"webui:<conversationId>"` |
| `senderId` | Authenticated Mosaic `userId` |
| `senderKind` | `"user"` for browser input; `"agent"` for agent responses |
| `content` | Message text from the input field |
| `contentKind` | `"text"` or `"markdown"` |
| `threadId` | Not used (conversation model handles threading) |
| `replyToId` | Message ID the user replied to (UI reply affordance) |
| `attachments` | Files uploaded via the file picker; stored to object storage |
| `timestamp` | `new Date()` at send, or server timestamp from event |
| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` |
**Outbound:** Adapter emits a `chat:message` Socket.IO event. The WebUI React component receives it and appends to the conversation list. Markdown content is rendered client-side via the existing markdown renderer component.
---
## Identity Mapping
Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity.
The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context).
```
channel_identities
channel_name TEXT -- e.g. "matrix", "discord"
channel_user_id TEXT -- channel-native user identifier
mosaic_user_id TEXT -- FK to users.id
linked_at TIMESTAMP
PRIMARY KEY (channel_name, channel_user_id)
```
Identity linking flows (OAuth dance, deep-link verification token, etc.) are out of scope for this document and will be specified in a separate identity-linking protocol document.
---
## Error Handling Conventions
- `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready.
- `ChannelEgressPort.send()` implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried.
- `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.
---
## Versioning
The `ChannelMessageDto` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
Current version: **1.0.0**
---
## M7-003: Matrix Integration Design
### Homeserver Choice
Mosaic uses **Conduit** as the Matrix homeserver. Conduit is written in Rust, ships as a single binary, and has minimal operational overhead compared to Synapse or Dendrite. It supports the full Matrix Client-Server and Application Service APIs required by Mosaic.
Recommended deployment: Conduit runs as a Docker container alongside the Mosaic stack. A single Conduit instance is sufficient for most self-hosted deployments. Conduit's embedded RocksDB storage means no separate database is required for the homeserver itself.
### Appservice Registration
Mosaic registers with the Conduit homeserver as a Matrix **Application Service (appservice)**. This gives Mosaic the ability to:
- Create and control ghost users (virtual Matrix users representing Mosaic agents and provisioned accounts).
- Receive all events sent to rooms within the appservice's namespace without polling.
- Send events on behalf of ghost users without separate authentication.
Registration is done via a YAML registration file (`mosaic-appservice.yaml`) placed in Conduit's configuration directory:
```yaml
id: mosaic
url: http://gateway:3000/_matrix/appservice
as_token: <random-secret>
hs_token: <random-secret>
sender_localpart: mosaic-bot
namespaces:
users:
- exclusive: true
regex: '@mosaic_.*:homeserver'
rooms:
- exclusive: false
regex: '.*'
aliases:
- exclusive: true
regex: '#mosaic-.*:homeserver'
```
The gateway exposes `/_matrix/appservice` endpoints to receive push events from Conduit. The `as_token` and `hs_token` are stored in Vault and injected at startup.
### Room ↔ Conversation Mapping
Each Mosaic conversation maps to a single Matrix room. The mapping is stored in the database:
```
conversation_matrix_rooms
conversation_id TEXT -- FK to conversations.id
room_id TEXT -- Matrix room ID (!roomid:homeserver)
created_at TIMESTAMP
PRIMARY KEY (conversation_id)
```
Room creation is handled by the appservice on the first Matrix access to a conversation. Room names follow the pattern `Mosaic: <conversation title>`. Room topics contain the conversation ID for correlation.
When a conversation is deleted or archived in Mosaic, the corresponding Matrix room is tombstoned (m.room.tombstone event) and the room is left in a read-only state.
### Space ↔ Team Mapping
Each Mosaic team maps to a Matrix **Space**. Spaces are Matrix rooms with a special `m.space` type that can contain child rooms.
```
team_matrix_spaces
team_id TEXT -- FK to teams.id
space_id TEXT -- Matrix room ID of the Space
created_at TIMESTAMP
PRIMARY KEY (team_id)
```
When a conversation room is shared with a team, the appservice adds it to the team's Space via `m.space.child` state events. Removing the share removes the child relationship.
### Agent Ghost Users
Each Mosaic agent is represented in Matrix as an **appservice ghost user**:
- Matrix user ID format: `@mosaic_agent_<agentId>:homeserver`
- Display name: the agent's human-readable name (e.g. "Mosaic Assistant")
- Avatar: optional, configurable per agent
Ghost users are registered lazily — the appservice creates the ghost on first use. Ghost users are controlled exclusively by the appservice; they cannot log in via Matrix client credentials.
When an agent sends a message via the gateway, the Matrix adapter sends the event using `user_id` impersonation on the appservice's client endpoint, causing the message to appear as if sent by the ghost user.
### Power Levels
Power levels in each Mosaic-managed room are set as follows:
| Entity | Power Level | Rationale |
| ------------------------------------- | -------------- | -------------------------------------- |
| Mosaic appservice bot (`@mosaic-bot`) | 100 (Admin) | Room management and moderation |
| Human Mosaic users | 50 (Moderator) | Can kick, redact, and invite |
| Agent ghost users | 0 (Default) | Message-only; cannot modify room state |
This arrangement ensures human users retain full control. An agent cannot modify room settings, kick members, or take administrative actions. Humans with moderator power can redact agent messages and intervene in ongoing conversations.
```
mermaid
graph TD
A[Mosaic Admin] -->|invites| B[Human User]
B -->|joins| C[Matrix Room / Conversation]
D[Agent Ghost User] -->|sends messages to| C
B -->|can redact/kick| D
E[Mosaic Bot] -->|manages room state| C
style A fill:#4a9eff
style B fill:#4a9eff
style D fill:#aaaaaa
style E fill:#ff9944
```
---
## M7-004: Conversation Multiplexing
### Architecture Overview
A single Mosaic conversation can be accessed simultaneously from multiple surfaces: TUI, WebUI, and Matrix. The gateway is the **single source of truth** for all conversation state. Each surface is a thin client that renders gateway-owned data.
```
┌─────────────────────────────────────────────────────┐
│ Gateway (NestJS) │
│ │
│ ConversationService ←→ MessageBus │
│ │ │ │
│ [DB: PostgreSQL] [Fanout: Valkey Pub/Sub] │
│ │ │
│ ┌─────────────────────┼──────────────┐ │
│ │ │ │ │
│ Socket.IO Socket.IO Matrix │ │
│ (TUI adapter) (WebUI adapter) (appservice)│ │
└──────────┼─────────────────────┼──────────────┘ │
│ │ │
CLI/TUI Browser Matrix
Client
```
### Real-Time Sync Flow
1. A message arrives on any surface (TUI keystroke, browser send, Matrix event).
2. The surface's adapter normalizes the message to `ChannelMessageDto` and delivers it to `ConversationService`.
3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`.
4. All active surfaces subscribed to that `conversationId` receive the fanout event and push it to their respective clients:
- TUI adapter: writes rendered output to the connected terminal session.
- WebUI adapter: emits a `chat:message` Socket.IO event to all browser sessions joined to that conversation.
- Matrix adapter: sends an `m.room.message` event to the conversation's Matrix room.
This ensures that a message typed in the TUI appears in the browser and in Matrix within the same round-trip latency as the Valkey fanout (typically <10 ms on co-located infrastructure).
### Surface-to-Transport Mapping
| Surface | Transport to Gateway | Fanout Transport from Gateway |
| ------- | ------------------------------------------ | ----------------------------- |
| TUI | HTTPS REST + SSE or WebSocket | Socket.IO over stdio proxy |
| WebUI | Socket.IO (browser) | Socket.IO emit |
| Matrix | Matrix Client-Server API (appservice push) | Matrix `m.room.message` send |
### Conflict Resolution
- **Messages**: Append-only. Messages are never edited in-place in Mosaic's canonical store. Matrix edit events (`m.replace`) are treated as new messages with `replyToId` pointing to the original, preserving the full audit trail.
- **Metadata (title, tags, archived state)**: Last-write-wins. The timestamp of the most recent write wins. Concurrent metadata updates from different surfaces are serialized through `ConversationService`; the final database write reflects the last persisted value.
- **Conversation membership**: Set-merge semantics. Adding a user from any surface is additive. Removal requires an explicit delete action and is not overwritten by concurrent adds.
### Session Isolation
Multiple TUI sessions or browser tabs connected to the same conversation receive all fanout messages independently. Each session maintains its own scroll position and local ephemeral state (typing indicator, draft text). Gateway does not synchronize ephemeral state across sessions.
---
## M7-005: Remote Auth Bridging
### Overview
Matrix users authenticate to Mosaic by linking their Matrix identity to an existing Mosaic account. There are two flows: token linking (primary) and OAuth bridge (alternative). Once linked, the Matrix session is persistent — there is no periodic login/logout cycle.
### Token Linking Flow
1. A Mosaic admin or the user themselves generates a short-lived link token via the Mosaic web UI or API (`POST /auth/channel-link-token`). The token is a cryptographically random 32-byte hex string with a 15-minute TTL stored in Valkey.
2. The user opens a Matrix client and sends a DM to `@mosaic-bot:homeserver`.
3. The user sends the command: `!link <token>`
4. The appservice receives the `m.room.message` event in the DM room, extracts the token, and calls `AuthService.linkChannelIdentity({ channel: 'matrix', channelUserId: matrixUserId, token })`.
5. `AuthService` validates the token, retrieves the associated `mosaicUserId`, and writes a row to `channel_identities`.
6. The appservice sends a confirmation reply in the DM room and invites the now-linked user to their personal Matrix Space.
```
User (Matrix) @mosaic-bot Mosaic Gateway
│ │ │
│ DM: !link <token> │ │
│────────────────────▶│ │
│ │ POST /auth/link │
│ │─────────────────────▶│
│ │ 200 OK │
│ │◀─────────────────────│
│ ✓ Linked! Joining │ │
│ your Space now │ │
│◀────────────────────│ │
```
### OAuth Bridge Flow
An alternative flow for users who prefer browser-based authentication:
1. The Mosaic bot sends the user a Matrix message containing an OAuth URL: `https://mosaic.example.com/auth/matrix-link?state=<nonce>&matrix_user=<encoded_mxid>`
2. The user opens the URL in a browser. If not already logged in to Mosaic, they are redirected through the standard BetterAuth login flow.
3. On successful authentication, Mosaic records the `channel_identities` row linking `matrix_user` to the authenticated `mosaicUserId`.
4. The gateway sends a Matrix event to the pending DM room confirming the link.
### Invite-Based Provisioning
When a Mosaic admin adds a new user account, the provisioning flow optionally associates a Matrix user ID with the new account at creation time:
1. Admin provides `matrixUserId` when creating the account (`POST /admin/users`).
2. `UserService` writes the `channel_identities` row immediately.
3. The Matrix adapter's provisioning hook fires, and the appservice:
- Creates the user's personal Matrix Space (if not already existing).
- Sends an invite to the Matrix user for their personal Space.
- Sends a welcome DM from `@mosaic-bot` with onboarding instructions.
The invited user does not need to complete any linking step — the association is pre-established by the admin.
### Session Lifecycle
Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessions (which terminate when the terminal process exits), a Matrix user's access to their rooms remains intact as long as:
- Their Mosaic account is active (not suspended or deleted).
- Their `channel_identities` row exists (link not revoked).
- They remain members of the relevant Matrix rooms.
Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) removes the `channel_identities` row and causes gateway principal resolution to deny the identity. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).
---
## M7-006: Agent-to-Agent Communication via Matrix
### Dedicated Agent Rooms
When two Mosaic agents need to coordinate, a dedicated Matrix room is created for their dialogue. This provides a persistent, auditable channel for structured inter-agent communication that humans can observe.
Room naming convention:
```
#mosaic-agents-<agentA>-<agentB>:homeserver
```
Where `agentA` and `agentB` are the Mosaic agent IDs sorted lexicographically (to ensure the same room is used regardless of which agent initiates). The room alias is registered by the appservice.
```
agent_rooms
room_id TEXT -- Matrix room ID
agent_a_id TEXT -- FK to agents.id (lexicographically first)
agent_b_id TEXT -- FK to agents.id (lexicographically second)
created_at TIMESTAMP
PRIMARY KEY (agent_a_id, agent_b_id)
```
### Room Membership and Power Levels
| Entity | Power Level |
| ---------------------------------- | ------------------------------------ |
| Mosaic appservice bot | 100 (Admin) |
| Human observers (invited) | 50 (Moderator, read-only by default) |
| Agent ghost users (agentA, agentB) | 0 (Default — message send only) |
Humans are invited to agent rooms with a read-only intent. By convention, human messages in agent rooms are prefixed with `[HUMAN]` and treated as interrupts by the gateway. Agents are instructed (via system prompt) to pause and acknowledge human messages before resuming their dialogue.
### Message Format
Agents communicate using **structured JSON** embedded in Matrix event content. The Matrix event type is `m.room.message` with `msgtype: "m.text"` for compatibility. The structured payload is carried in a custom `mosaic.agent_message` field:
```json
{
"msgtype": "m.text",
"body": "[Agent message — see mosaic.agent_message for structured content]",
"mosaic.agent_message": {
"schema_version": "1.0",
"sender_agent_id": "agent_abc123",
"conversation_id": "conv_xyz789",
"message_type": "request",
"payload": {
"action": "summarize",
"parameters": { "max_tokens": 500 },
"reply_to_event_id": "$previousEventId"
},
"timestamp_ms": 1711234567890
}
}
```
The `body` field contains a human-readable fallback so the conversation is legible in any Matrix client. The structured payload is parsed exclusively by the gateway's Matrix adapter.
### Coordination Patterns
**Request/Response**: Agent A sends a `message_type: "request"` event. Agent B sends a `message_type: "response"` with `reply_to_event_id` referencing Agent A's event. The gateway correlates request/response pairs using the event IDs.
**Broadcast**: An agent sends a `message_type: "broadcast"` to a multi-agent room (more than two members). All agents in the room receive the event. No response is expected.
**Delegation**: Agent A sends a `message_type: "delegate"` with a `payload.task` object describing work to be handed off to Agent B. Agent B acknowledges with `message_type: "delegate_ack"` and later sends `message_type: "delegate_complete"` when done.
```
AgentA Gateway AgentB
│ delegate(task) │ │
│────────────────────▶│ │
│ │ Matrix event push │
│ │────────────────────▶│
│ │ delegate_ack │
│ │◀────────────────────│
│ │ [AgentB executes] │
│ │ delegate_complete │
│ │◀────────────────────│
│ task result │ │
│◀────────────────────│ │
```
### Gateway Mediation
Agents do not call the Matrix Client-Server API directly. All inter-agent Matrix events are sent and received by the gateway's appservice. This means:
- The gateway can intercept, log, and rate-limit agent-to-agent messages.
- Agents that are offline (no active process) still have their messages delivered; the gateway queues them and delivers on the agent's next activation.
- The gateway can inject system messages (e.g. human interrupts, safety stops) into agent rooms without agent cooperation.
---
## M7-007: Multi-User Isolation in Matrix
### Space-per-Team Architecture
Isolation in Matrix is enforced through the Space hierarchy. Each organizational boundary in Mosaic maps to a distinct Matrix Space:
| Mosaic entity | Matrix Space | Visibility |
| ----------------------------- | -------------- | ----------------- |
| Personal workspace (per user) | Personal Space | User only |
| Team | Team Space | Team members only |
| Public project | (no Space) | Configurable |
Rooms (conversations) are placed into Spaces based on their sharing configuration. A room can appear in at most one team Space at a time. Moving a room from one team Space to another removes the `m.space.child` link from the old Space and adds it to the new one.
### Room Visibility Rules
Matrix room visibility within Conduit is controlled by:
1. **Join rules**: All Mosaic-managed rooms use `join_rule: invite`. Users cannot discover or join rooms without an explicit invite from the appservice.
2. **Space membership**: Rooms appear in a Space's directory only to users who are members of that Space.
3. **Room directory**: The server room directory is disabled for Mosaic-managed rooms (`m.room.history_visibility: shared` for team rooms, `m.room.history_visibility: invited` for personal rooms).
### Personal Space Defaults
When a user account is created (or linked to Matrix), the appservice provisions a personal Space:
- Space name: `<username>'s Space`
- All conversations the user creates personally are added as children of their personal Space.
- No other users are members of this Space by default.
- Conversation rooms within the personal Space are only visible and accessible to the owner.
### Team Shared Rooms
When a project or conversation is shared with a team:
1. The appservice adds the room as a child of the team's Space (`m.space.child` state event in the Space room, `m.space.parent` state event in the conversation room).
2. All current team members are invited to the conversation room.
3. Newly added team members are automatically invited to all shared rooms in the team's Space by the appservice's team membership hook.
4. If sharing is revoked, the appservice removes the `m.space.child` link and kicks all team members who joined via the team share (users who were directly invited are unaffected).
### Encryption
Encryption is optional and configured per room at creation time. Recommended defaults:
| Space type | Encryption default | Rationale |
| -------------- | ------------------ | -------------------------------------- |
| Personal Space | Enabled | Privacy-first for individual users |
| Team Space | Disabled | Operational visibility; admin auditing |
| Agent rooms | Disabled | Gateway must read structured payloads |
When encryption is enabled, the appservice's ghost users must participate in key exchange (using Matrix's Olm/Megolm protocol). The gateway holds the device keys for all ghost users it controls. This constraint means encrypted rooms require the gateway to be the E2E session holder — messages are end-to-end encrypted between human clients and gateway-held ghost device keys, not between human clients themselves.
### Admin Visibility
A Conduit server administrator can see:
- Room metadata: names, aliases, topic, membership list.
- Unencrypted event content in unencrypted rooms.
A Conduit server administrator **cannot** see:
- Content of encrypted rooms (without holding a device key for a room member).
Mosaic does not grant gateway admin credentials to application-level admin users. The Conduit admin interface is restricted to infrastructure operators. Application-level admins manage users and rooms through the Mosaic API, which interacts with the appservice layer only.
### Data Retention
Matrix events in Mosaic-managed rooms follow Mosaic's configurable retention policy:
```
room_retention_policies
room_id TEXT -- Matrix room ID (or wildcard pattern)
retention_days INT -- NULL = keep forever
applies_to TEXT -- "personal" | "team" | "agent" | "all"
created_at TIMESTAMP
```
The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessageDto` record unless the Mosaic retention policy also covers it.
Default retention values:
| Room type | Default retention |
| --------------------------- | ------------------- |
| Personal conversation rooms | 365 days |
| Team conversation rooms | 730 days |
| Agent-to-agent rooms | 90 days |
| System/audit rooms | 1825 days (5 years) |
Retention settings are configurable by Mosaic admins via the admin API and apply to both the Matrix event store and the Mosaic message store in lockstep.
@@ -1,9 +1,5 @@
# Compaction observer revocation and runtime generations
> **Status:** Current contract reference.
> **Audience:** Developer and security reviewer.
> **Evidence:** Mutator-gate and framework portability acceptance suites consume this page; live deployment gaps remain explicitly labeled below.
WI-3 connects Claude and Pi compaction/session lifecycle events to the existing authenticated lease-broker state machine. It does not add a second lease store or let runtime hooks assert identity. Each observer inherits the broker-minted session, resolves the current private runtime generation, and sends the existing `revoke_lease` action over the authenticated Unix socket.
## Observer matrix
@@ -1,9 +1,5 @@
# Authenticated external lease broker protocol
> **Status:** Current contract reference.
> **Audience:** Developer and security reviewer.
> **Evidence:** Lease-broker acceptance suites consume this page; executable behavior remains authoritative in source and tests.
The compaction-refresh lease broker is a Linux-only, newline-framed JSON protocol over a Unix stream socket. It is runtime-neutral; M1 consumers are limited to Claude and Pi. This is an internal process boundary, not an HTTP API, so it is intentionally absent from OpenAPI.
The broker, never the caller, obtains `(pid, uid, gid)` from kernel `SO_PEERCRED`. It correlates the PID with `/proc/<pid>/stat` field 22 (`starttime`) and mints `session_id` on `register_anchor`. Presence of `session_id` in that request is refused even when its value is `null` or empty. Later requests must originate from the anchor or a descendant. The broker walks parent PIDs to the `(pid,starttime)` anchor and then rereads every walked PID's starttime before accepting the chain.
@@ -1,9 +1,5 @@
# WI-1 lease broker security notes
> **Status:** Current contract reference.
> **Audience:** Developer and security reviewer.
> **Evidence:** The lease-broker implementation and acceptance material cross-check this boundary; deployment-review requirements remain explicitly labeled below.
- Trusted identity comes only from Linux `SO_PEERCRED` plus `/proc` starttime, never request identity fields.
- Descendant authorization is anchored to `(pid,starttime)` and uses a complete second starttime pass to fail closed on disappearance or PID-reuse races.
- Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits. WI-3 stores the live generation in an owner-only locked file so same-PID Pi reload/new/resume/fork and Claude resume/clear transitions cannot inherit a VERIFIED lease.
@@ -0,0 +1,49 @@
# Mos Runtime Portability M1 — Logical Identity and Fencing
## Boundary
M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The normalized identity is:
```text
(tenant_id, logical_agent_id, binding_id)
```
`logical_agent_id` is a server-owned stable identifier. A connector is a replaceable holder of a lease for one binding; it is not the agent identity.
## Durable lease model
PostgreSQL table `logical_agent_connector_leases` has one unique row per identity/binding tuple. The current row records:
- an opaque lease UUID;
- connector ID and normalized allowed scopes;
- a positive decimal fencing epoch stored as PostgreSQL `bigint`;
- acquired, heartbeat, expiry, release, and update timestamps.
Initial acquisition is insert-only. An existing active row causes `lease_held`. An expired or released row causes `takeover_required`; ordinary acquisition cannot recover it. Authorized takeover uses compare-and-swap against the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch.
The companion `connector_lease_audit_log` is append-only metadata. It stores lifecycle event, outcome/reason, identity/binding/connector, epoch, correlation ID, and timestamp. It deliberately excludes scopes, grant objects, payloads, approval references, tokens, and credentials.
## Execution grants
`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. Defense-in-depth caps leases at 5 minutes and grants at 30 seconds by default; constructor options may tighten these limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, and epoch.
Validation occurs immediately before adapter invocation and rereads PostgreSQL. The adapter receives only `ConnectorExecutionContext`; harness-native schemas remain behind the adapter. Validation denies:
- grants not minted by the current gateway process (including cloned/forged objects);
- expired grants or leases;
- released leases;
- stale epochs or replaced connector/lease UUIDs;
- missing/cross-tenant/cross-agent/cross-binding leases;
- scopes not authorized by both grant and current lease.
A gateway restart intentionally invalidates process-local grants. The durable lease and epoch survive, and a fresh grant may be issued only after current-lease and gateway-policy validation.
## Concurrency and side-effect rule
The database CAS determines the sole current holder. A successful takeover makes every old-epoch validation fail. Connector adapters must consume and propagate the normalized lease epoch/context so downstream effect boundaries can also fence races that occur after gateway validation.
M1 does not provide exactly-once receipts or a side-effect journal. Those remain later #754 work; callers must not infer exactly-once delivery from lease fencing.
## Extension boundary
`ConnectorLeaseService` is the gateway-owned policy surface. Every policy decision receives the normalized requested scopes and TTL (or explicit `null` where no TTL applies), so a concrete policy can enforce least privilege and duration limits. Its production default policy denies every lease/grant operation until a server-configured connector policy is supplied. No M1 HTTP endpoint accepts caller-controlled tenant or logical identity, and no concrete Claude/Pi/Codex adapter or channel cutover is included.
@@ -1,9 +1,5 @@
# Whole mutator-class lease gate
> **Status:** Current contract reference.
> **Audience:** Developer and security reviewer.
> **Evidence:** Runtime launch-guard and mutator-gate tests cross-check this boundary; parser residuals and deployment gaps remain explicitly labeled below.
WI-2 adds the framework-native authorization boundary for Claude (including the supported Claudex overlay) and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only.
## Default-deny policy
-35
View File
@@ -1,35 +0,0 @@
# Documentation Archive
> **Status:** Current archive index. Pages linked here are historical or superseded and are not current product, operator, or developer guidance.
Use [`docs/README.md`](../README.md) for current placement and source-of-truth rules. Historical pages remain discoverable here only when retaining their context is useful; each migration should identify a replacement or explain why the record is retained.
## Archived TUI workstream
The following branch-specific records are retained because their implementation claims and worktree paths no longer match the current checkout:
- [`TUI improvements PRD`](tui/PRD-TUI_Improvements.md) — historical Phase 7 requirements; it names the deleted `packages/cli` package.
- [`TUI improvements task ledger`](tui/TASKS-TUI_Improvements.md) — historical task/status record; its relative PRD link remains valid within this archive directory.
Do not use these pages as instructions for the current TUI. Current TUI implementation is under `packages/mosaic`; any new requirements require a separately approved plan or PRD.
## Archived missions
- [`Mission archive index`](missions/README.md) — completed and superseded CLI, harness, install UX, and storage-abstraction mission records.
Archived mission manifests and task ledgers preserve their original status and context. They do not replace current orchestrator-owned [`docs/TASKS.md`](../TASKS.md) or authorize old installation procedures.
## Archived planning
- [`Planning archive index`](planning/README.md) — historical briefs, reviews, and work-package specifications.
- [`Monorepo consolidation bundle`](planning/monorepo-consolidation/README.md) — prior Forge, MACP, and framework-plugin consolidation planning. Current package existence does not validate every historical criterion.
## Archived work records
- [`Work-record archive`](work-records/README.md) — unreferenced historical task scratchpads retained as evidence, not active status or guidance.
## Retention rules
- Preserve historical wording unless a migration task explicitly requires a rewrite.
- Label replacements and current status in the owning index rather than silently reviving archived claims.
- Do not link archive pages from current workflow instructions as if they were current.
-37
View File
@@ -1,37 +0,0 @@
# Archived Missions
> **Status:** Historical mission index. These records describe completed or superseded delivery work and are not current task state, requirements, installation guidance, or command authority.
## CLI unification — 2026-04-04
- [Mission manifest](cli-unification-20260404/MISSION-MANIFEST.md)
- [Task ledger](cli-unification-20260404/TASKS.md)
## Harness foundation — 2026-03-21
- [Mission manifest](harness-20260321/MISSION-MANIFEST.md)
- [Scoped PRD](harness-20260321/PRD.md)
## Install UX hardening — 2026-04-05
- [Mission manifest](install-ux-hardening-20260405/MISSION-MANIFEST.md)
- [Task ledger](install-ux-hardening-20260405/TASKS.md)
## Install UX v2 — 2026-04-05
- [Mission manifest](install-ux-v2-20260405/MISSION-MANIFEST.md)
- [Task ledger](install-ux-v2-20260405/TASKS.md)
- [IUV-M03 design](install-ux-v2-20260405/iuv-m03-design.md)
- [Orchestrator scratchpad](install-ux-v2-20260405/scratchpad.md)
## Storage abstraction retrofit
- [Task ledger](storage-abstraction/TASKS.md)
Historical statuses, commands, package paths, and completion claims are retained for provenance and may not match the current checkout. Use [`docs/TASKS.md`](../../TASKS.md) only for orchestrator-owned current task state.
## Related
- [[archive/README|Documentation archive]]
- [[SITEMAP|Documentation sitemap]]
- [[reports/README|Documentation reports]]
@@ -12,7 +12,7 @@
**Progress:** 3 / 3 milestones
**Status:** complete
**Last Updated:** 2026-04-05 (mission complete)
**Parent Mission:** [cli-unification-20260404](../cli-unification-20260404/MISSION-MANIFEST.md) (complete)
**Parent Mission:** [cli-unification-20260404](./archive/missions/cli-unification-20260404/MISSION-MANIFEST.md) (complete)
## Context
@@ -13,7 +13,7 @@
**Status:** complete
**Last Updated:** 2026-04-19 (archived during MVP manifest authoring; IUV-M03 substantively shipped via PR #446 — drill-down menu + provider-first flow + quick start; releases 0.0.27 → 0.0.29)
**Archived to:** `docs/archive/missions/install-ux-v2-20260405/`
**Parent Mission:** [install-ux-hardening-20260405](../install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
**Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
## Context
-15
View File
@@ -1,15 +0,0 @@
# Archived planning records
> **Status:** Historical planning index. These records preserve prior intent and review context; they are not current requirements, task state, implementation evidence, or operational authority.
## Monorepo consolidation
- [Planning bundle](monorepo-consolidation/README.md) — historical brief, board review, and Forge/MACP/framework-plugin work-package specifications.
## Legacy plans and deferred stubs
- [Legacy planning index](legacy/README.md) — unreferenced implementation plans, a superseded SSO setup record, and explicitly deferred design stubs.
- [Archived Matrix/MACP proposals](matrix-macp/README.md) — historical draft communications and deployment RFCs for functionality not established by current source/tests.
- [Archived standalone designs](designs/README.md) — historical prerelease-pipeline and storage-abstraction designs.
Use current package source, tests, audience guides, and approved control documents for present behavior and status.
-8
View File
@@ -1,8 +0,0 @@
# Archived standalone designs
> **Status:** Historical design records. These files moved byte-identically from migration quarantine on 2026-08-10 and are not current implementation or release contracts.
- [npm prerelease `@next` lane](prerelease-next-dist-tag-pipeline.md) — prior release-pipeline design; verify current CI and package scripts before use.
- [Storage and queue abstraction](storage-abstraction-middleware.md) — prior middleware/tier design. Current storage abstractions exist, but this record does not prove its complete target architecture or operational procedures.
Use current package source, manifests, tests, and canonical safety guidance for present behavior. The coupled #791 upgrade design and normative framework constitution remain in migration quarantine pending their owning workstreams.
-28
View File
@@ -1,28 +0,0 @@
# Legacy plans and deferred design stubs
> **Status:** Historical planning archive. These files preserve prior proposals and implementation approaches; they are not proof of shipped behavior or authority to run commands.
The records below moved byte-identically from migration quarantine on 2026-08-10. Validate every claim against current source, tests, configuration, and safety policy before reuse.
## Implementation plans
- [Gateway security hardening](2026-03-13-gateway-security-hardening.md)
- [Agent platform architecture](2026-03-15-agent-platform-architecture.md)
- [Wave 2 TUI layout and navigation](2026-03-15-wave2-tui-layout-navigation.md)
- [HermesMosaic alignment](2026-05-06-hermes-mosaic-alignment.md)
- [Coordination resilience](2026-05-07-coordination-resilience.md)
- [Gateway token recovery](gateway-token-recovery.md)
## Setup record
- [Authentik SSO setup](authentik-sso-setup.md) — superseded for current administration by the canonical [SSO provider guide](../../../ADMIN-GUIDE/security/sso-providers.md).
## Explicitly deferred stubs
- [Chroot agent sandboxing](chroot-sandboxing.md)
- [Gatekeeper service](gatekeeper-service.md)
- [Task queue unification](task-queue-unification.md)
## Exclusions
The Agent Reflection PRD remains in quarantine because a live MACP test names its intended canonical path. The WebUI/Fleet Claude bridge draft remains authority-gated and coupled to Fleet decisions. Neither was moved in this archival slice.
@@ -1,10 +0,0 @@
# Archived Matrix/MACP proposals
> **Status:** Historical draft proposals. These records moved byte-identically from migration quarantine on 2026-08-10 and have no implementation or operational authority.
- [RFC-001: MACP Matrix-native communications](rfc-001-macp-matrix-native.md)
- [RFC-002: install, configuration, and topology](rfc-002-install-config-topology.md)
Current source and focused tests do not establish the proposed Matrix adapter, identity mapping, persistence, homeserver/appservice topology, or federation operations. See the canonical [channel protocol](../../../DEVELOPER-GUIDE/architecture/channel-protocol.md) for the implemented Discord boundary and explicit Matrix limitations.
Do not use these archived RFCs as deployment instructions or as evidence that Matrix/MACP functionality shipped.
@@ -1,19 +0,0 @@
# Monorepo consolidation planning bundle
> **Status:** Historical planning evidence. The five source records were moved byte-identically from migration quarantine on 2026-08-10.
This bundle records the decision and proposed work packages for consolidating prior Forge, MACP, and OpenClaw framework work into this monorepo.
## Records
- [Consolidation brief](brief.md) — original scope, target layout, constraints, and success criteria.
- [Board review](board-review.md) — historical deliberation and conditional approval.
- [WP1: Forge package](wp1-forge-package.md) — proposed TypeScript Forge implementation.
- [WP2: MACP package](wp2-macp-package.md) — proposed protocol, gate, credential, and event implementation.
- [WP3: Mosaic framework plugin](wp3-mosaic-framework-plugin.md) — proposed OpenClaw framework plugin port.
## Current boundary
`packages/forge`, `packages/macp`, and `plugins/mosaic-framework` exist in the current checkout. That existence is sufficient to classify this bundle as historical planning, but it does **not** prove that every stated success criterion, integration, coverage target, or behavior remains satisfied.
Use current package source, manifests, and tests for implementation truth. Do not use this bundle as an active task ledger or as authority to change package behavior.
-19
View File
@@ -1,19 +0,0 @@
# Archived work records
> **Status:** Historical evidence index. These scratchpads record prior task execution and investigation; they are not active task state, current requirements, implementation contracts, or operational authority.
Ninety-four records moved byte-identically from migration quarantine on 2026-08-10 after a repository-wide consumer scan found no current path/name references and no coupled local Markdown links.
Because this collection is large, use repository search by issue, task, or topic rather than treating every file as current navigation. Validate all technical claims and commands against current source, tests, configuration, and safety policy.
## Retained in quarantine
Seventeen scratchpads were deliberately not moved because they are named by current control documents, tests/fixtures, retained mission/evidence records, or have a coupled KBN-101 report link. They must migrate with their owning workstream or consumer update.
## Boundaries
- Historical completion wording does not update `docs/TASKS.md` or mission authority.
- Old commands are not approved runbooks.
- Old security findings are not proof of current posture.
- Draft designs are not current architecture.
- Files remain byte-identical; this index supplies the lifecycle classification.
+1 -1
View File
@@ -129,7 +129,7 @@ systemctl --user restart mosaic-agent@<name>
Full recovery runbook and the three-layer #791 protection model (manifest
ownership → pre-update snapshot/restore → regen): see
[Upgrade Safety & Recovery](../ADMIN-GUIDE/operations/upgrade-safety-and-recovery.md).
[Upgrade Safety & Recovery](./upgrade-safety-and-recovery.md).
## Release Preflight
+44
View File
@@ -0,0 +1,44 @@
# Lease broker operations
Place the socket and state file in a dedicated directory with mode `0700`. Start the packaged daemon with:
```bash
python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \
--socket /run/user/1000/mosaic-lease/broker.sock \
--state /run/user/1000/mosaic-lease/state.json
```
The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path.
Before launching Claude, Claudex, or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper:
```bash
export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock
mosaic claude # or: mosaic claudex, mosaic yolo claudex, mosaic pi
```
The wrapper obtains a broker-minted session ID, creates a private `generation-<session>.state` file beside the socket, and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity and read the current generation from the file. Claudex retains its isolated proxy environment and config directory; Mosaic merges the mandatory all-tools and compaction-lifecycle hooks into that isolated `settings.json` before invoking the same wrapper. PRDY init/update, QA remediation, coord, orchestrator, and fleet launchers also converge on this boundary. Broker registration failure, unsafe isolated settings, unsafe generation state, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools.
Claude `PreCompact` and `SessionStart(compact)` hooks and Pi pre-/post-compaction handlers invoke `revoke-lease.py`. Pi `session_start` reload/new/resume/fork and Claude resume/clear advance the locked generation before revocation, so a replacement session inherits no lease even when PID/starttime stay unchanged. Do not invoke the revoker manually as a way to restore authority; it only removes authority. If a lifecycle hook reports failure, stop consequential work and repair broker/generation-state availability before re-verification.
Run the permanent launch inventory locally with:
```bash
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root .
```
The same check runs in the Mosaic package test suite and therefore in root CI. Any direct Claude/Pi binary launch must be replaced with `launch-runtime.py`, `execLeaseGatedRuntime`, or the gated `mosaic` runtime command; do not add static allowlist exceptions.
Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected.
`mosaic_context_recover` is the only unverified mutator class. Its durable `mosaic-context-refresh` skill is a thin wrapper over `tools/lease-broker/recover-context.py`: `begin` has the broker rebuild the validated `B_payload`/`H_payload`, revoke first, and mint a new `PENDING_DELIVERY` receipt challenge; `complete` accepts neither receipt text nor a challenge argument. Claude maps only the exact direct recovery executable/validated arguments to this exempt tool identity; ordinary `Bash` remains gated. Pi exposes only the `mosaic_context_recover` custom tool; ordinary `bash` and all other tools remain gated. A normal-path receipt cannot be replayed through recovery because each retry begins a distinct recovery cycle and recovery completion cannot receive caller-presented evidence.
Production daemon startup creates a separate private observer socket unless a test-only `--test-observer-file` fixture is selected. Claude's Stop hook sends its exact latest assistant entry and Pi's `message_end` handler sends only finalized assistant content to that authenticated transport; the broker public socket never accepts message text. This is byte-build and private out-of-process harness wiring only: do not activate it against a live daemon, live socket, systemd service, tmux session, or model-output stream outside the controlled integration procedure.
Receipt honesty is load-bearing: absent, malformed, prefix-truncated, and observable adapter-mutated terminal receipts do not promote. A tail-only case is non-promoting only where the concrete terminal payload is malformed or observably incomplete. A tail-preserving middle drop is **not receipt-detectable**; it is the disclosed T-C injection-contract residual deferred to WI-7 server-side evidence. The receipt remains a T-A delivery/liveness prerequisite, never a safety, obedience, or residency proof. The framework skill is source-resident and bridge-projected on install/upgrade; do not hand-create a live runtime symlink.
After a runtime exits, its `generation-<session>.state` file may be removed only after verifying that no process for that broker-minted session remains; stale files carry no lease authority but should be retained during incident analysis. After a broker crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens.
## Security posture
Directory `0700` plus socket/state `0600` is built-in same-principal hardening only: it excludes other UIDs but does **not** stop the same UID from unlinking and counterfeiting the socket. It therefore does not close T-C same-UID replacement. WI-1 does not provide a distinct-principal boundary. A stronger distinct-principal deployment requires an external protected proxy, ACL, or service boundary that clients cannot unlink or rebind and that preserves the authenticated client identity required by the broker's `SO_PEERCRED` and ancestry checks. Server-side branch protection remains the irreducible backstop.
@@ -0,0 +1,43 @@
# Mos Connector Lease Operations — M1
## Operational status
M1 installs the durable schema and gateway policy/adapter boundary. It does **not** activate a connector, expose a lease administration endpoint, or cut over a channel. The default gateway connector-lease policy is deny-all until a later work package supplies an authorized server-side policy and concrete adapter.
## Events to monitor
Use correlation IDs to follow `connector_lease_audit_log` events:
| Event | Meaning |
| ---------- | --------------------------------------------------------------------- |
| `acquire` | First holder inserted for an unused binding |
| `renew` | Current holder heartbeat extended the TTL |
| `takeover` | Authorized CAS replaced the holder and incremented epoch |
| `release` | Current holder explicitly relinquished authority |
| `expiry` | An expired current lease was observed |
| `reject` | Policy, CAS, expiry, scope, or fencing validation denied an operation |
Audit data is metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output.
## Incident checks
For suspected duplicate/stale connector effects:
1. Correlate the attempted operation with its `reject`, `takeover`, or `expiry` event.
2. Compare the current row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context.
3. Treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative. Do not retry it as the old holder.
4. Recovery uses the authorized takeover path with the observed expected epoch. Ordinary acquire is intentionally rejected for expired/released rows.
5. If an external effect may already have happened, preserve evidence and do not assume lease fencing provides exactly-once replay safety.
## Migration and rollback safety
Migration `0016_salty_morlocks.sql` is additive: it creates two new tables and indexes without modifying existing authorization/session tables. Before rollout, normal database backup and migration verification still apply. Rolling application code back leaves unused additive tables in place; dropping tables is not part of automated rollback because it would destroy lease/audit evidence.
## Security constraints
- Tenant comes from authenticated gateway context, never a connector request field.
- Logical agent, binding, connector, and scope identifiers use normalized constrained forms.
- Takeover requires explicit gateway policy authorization and an expected epoch.
- Default defense-in-depth TTL caps are 5 minutes for leases and 30 seconds for grants; policy may enforce stricter limits.
- Validation and rejection audit complete before adapter side effects.
- Existing authz and exact-action approval controls remain additional required gates; a valid connector lease does not bypass them.
+147
View File
@@ -0,0 +1,147 @@
# Upgrade Safety & Recovery
How Mosaic protects operator-owned configuration under `~/.config/mosaic` across
framework upgrades, and how to recover if a projection is ever lost.
A framework upgrade runs `install.sh` in keep-mode (`MOSAIC_INSTALL_MODE=keep`,
`MOSAIC_SYNC_ONLY=1`) to refresh framework-owned files in place. The incident
this hardening addresses: an upgrade that silently overwrites or deletes a file
the operator owns — credentials, personas, a roster, or a generated agent env —
with no snapshot to fall back to.
Protection is layered. Each layer is independent; a later layer catches what an
earlier one misses.
## Layer 1 — Manifest-owned sync (prevention)
The single source of truth for ownership is
[`framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt).
Both the bash installer and the TypeScript sync path resolve every path against
this one file (parity is enforced by test), so they can never drift.
- Ownership is **allow-list, deny-wins**: a path is framework-owned only if a
`[framework]` glob matches and no `[operator]` carve-out overrides it.
- **Unknown paths default to operator** (fail-safe): a file the manifest never
anticipated is treated as operator-owned and is never pruned.
- Keep-mode does a non-deleting copy plus an explicit, manifest-scoped prune that
only ever iterates framework globs — operator and unknown paths are
structurally unreachable by the prune.
Result: a correct upgrade cannot touch operator config at all.
## Layer 2 — Durable pre-update snapshot + verify net (safety + rollback)
Before **any** mutation, the installer snapshots the operator-owned surface that
exists into:
```
${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-timestamp>/
```
- `0700` directories / `0600` files (`umask 077`, scoped and restored),
outside `~/.config/mosaic` and outside any repo.
- **Fail-open**: a snapshot failure warns but never aborts the upgrade it
protects.
- Retention is `MOSAIC_BACKUP_RETENTION` snapshots (default 5).
After the sync, a **verify net** compares each snapshot file against its target
and restores (with a loud warning) any operator file the upgrade diverged or
removed — a divergence means a manifest bug slipped through Layer 1.
Inspect and restore snapshots with the CLI:
```bash
mosaic restore --list # dry-run: enumerate snapshots by timestamp
mosaic restore --from <UTC-timestamp> # restore the operator surface from one snapshot
mosaic restore --from <ts> --dry-run # preview a specific restore without writing
```
`mosaic restore` reports **counts and relative paths only** — it never emits file
contents, so a secret in `tools/_lib/credentials.json` is never echoed. Restores
are confirmation-gated (`--yes` or `MOSAIC_ASSUME_YES`) and write each leaf
atomically with `O_NOFOLLOW` (a symlink swapped in after the snapshot fails
closed rather than following out of the managed tree).
## Layer 3 — Regeneration from roster SSOT (recovery)
Some operator files are **derived** and do not need a byte-for-byte snapshot to
recover — they can be rebuilt from their source of truth. The fleet's per-agent
generated env projections are the prime case:
- `~/.config/mosaic/fleet/agents/<name>.env.generated` is a deterministic
projection of `~/.config/mosaic/fleet/roster.yaml`.
- The launcher (`start-agent-session.sh`, invoked by
`mosaic-agent@<name>.service`) sources that generated projection to establish
each agent's identity, runtime, model, and working directory. If it is missing
or wrong, the agent cannot launch with its intended identity.
`mosaic fleet regen` rebuilds those projections from the roster SSOT:
```bash
mosaic fleet regen # dry-run (default): show what would be rebuilt
mosaic fleet regen --json # same, machine-readable
mosaic fleet regen --write # rebuild the projections on disk
```
- **Dry-run by default.** Nothing is written until you pass `--write`.
- **Deterministic and idempotent** — the projection is a pure function of the
roster, so repeated `--write` runs produce byte-identical files.
- **Projection-only. It never restarts an agent.** Recovery order forbids
restart-before-verify; `regen` has no path to systemd lifecycle at all.
- **It rebuilds only `<name>.env.generated`** — it never writes, relocates, or
deletes the operator-owned `.env` / `.env.local` surface.
- It **validates the roster the same way `reconcile` does** (persona resolution
and protected-class tool-policy match), so a hand-edited or corrupt roster is
rejected rather than projected, and a `--write` takes the shared reconcile
lock so it cannot race a concurrent reconcile.
- Output is **paths and counts only** — the rendered `KEY=value` body is never
echoed.
`regen` uses the exact same roster→env mapping as `mosaic fleet reconcile`, so a
recovered projection matches what a normal reconcile would have written.
## Recovery runbook — wiped `fleet/agents/*.env.generated`
If an upgrade (or a manual mistake) has left an agent without its generated
projection, **do not restart the unit first** — a launch against a missing
projection fails closed, and any stale state must be corrected before restart,
not after.
1. **Prefer a snapshot restore if one exists** (byte-exact operator state):
```bash
mosaic restore --list
mosaic restore --from <UTC-timestamp>
```
2. **Otherwise regenerate the derived projections from the roster SSOT:**
```bash
mosaic fleet regen # confirm the plan (create vs rebuild per agent)
mosaic fleet regen --write # rebuild fleet/agents/<name>.env.generated
```
3. **Verify each unit will resolve the intended runtime/workdir _before_ any
restart.** The unit sets **no** `EnvironmentFile=` — it launches from a minimal
environment and `start-agent-session.sh` sources `.env.generated` itself, so
verify the generated file directly and confirm the launcher path:
```bash
# Confirm fleet/agents/<name>.env.generated exists and carries the intended
# MOSAIC_AGENT_* values (name, runtime, model, workdir, socket).
test -f ~/.config/mosaic/fleet/agents/<name>.env.generated
# Confirm the unit launches the session script that reads it.
systemctl --user cat mosaic-agent@<name> | grep ExecStart
```
4. **Only then restart, one unit at a time:**
```bash
systemctl --user restart mosaic-agent@<name>
```
## See also
- Design: [`docs/design/791-upgrade-config-protection.md`](../design/791-upgrade-config-protection.md)
- Fleet operations: [`docs/guides/fleet-local-canary.md`](./fleet-local-canary.md)
- Ownership SSOT: [`packages/mosaic/framework/framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt)

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