Compare commits
5 Commits
17932c3fb1
...
docs/753-k
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd3d547db0 | ||
|
|
9b55de0f91 | ||
| 49e8a54105 | |||
| d077183554 | |||
| 405984af5a |
@@ -7,7 +7,7 @@ import {
|
||||
} from '@mosaicstack/discord-plugin';
|
||||
import { InteractionController } from '../../agent/interaction.controller.js';
|
||||
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
||||
import { TessDurableSessionService } from '../../agent/tess-durable-session.service.js';
|
||||
import { DurableSessionService } from '../../agent/durable-session.service.js';
|
||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
||||
|
||||
@@ -51,7 +51,7 @@ function authorization(): CommandAuthorizationService {
|
||||
);
|
||||
}
|
||||
|
||||
describe('Tess Discord/CLI durable-session integration', () => {
|
||||
describe('interaction Discord/CLI durable-session integration', () => {
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
const value = priorEnv.get(key);
|
||||
@@ -80,7 +80,7 @@ describe('Tess Discord/CLI durable-session integration', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const durable = new TessDurableSessionService(
|
||||
const durable = new DurableSessionService(
|
||||
new InMemoryDurableSessionStore() as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
@@ -11,8 +11,8 @@ import { SessionsController } from './sessions.controller.js';
|
||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||
import { InteractionController } from './interaction.controller.js';
|
||||
import { RoutingController } from './routing/routing.controller.js';
|
||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||
import { DurableSessionService } from './durable-session.service.js';
|
||||
import { CoordModule } from '../coord/coord.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { SkillsModule } from '../skills/skills.module.js';
|
||||
@@ -44,8 +44,8 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
||||
RoutingService,
|
||||
RoutingEngineService,
|
||||
SkillLoaderService,
|
||||
TessDurableSessionRepository,
|
||||
TessDurableSessionService,
|
||||
DurableSessionRepository,
|
||||
DurableSessionService,
|
||||
{
|
||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
useFactory: createGatewayRuntimeProviderRegistry,
|
||||
@@ -76,7 +76,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
||||
RoutingService,
|
||||
RoutingEngineService,
|
||||
SkillLoaderService,
|
||||
TessDurableSessionService,
|
||||
DurableSessionService,
|
||||
RuntimeProviderService,
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
||||
|
||||
/** Server-side request for a replay-safe provider message. */
|
||||
export interface TessProviderOutboxDto {
|
||||
export interface ProviderOutboxDto {
|
||||
sessionId: string;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
@@ -6,8 +6,8 @@ import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/
|
||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||
import { DurableSessionService } from './durable-session.service.js';
|
||||
|
||||
const IDENTITY: DurableSessionIdentity = {
|
||||
agentName: 'Nova',
|
||||
@@ -18,7 +18,7 @@ const IDENTITY: DurableSessionIdentity = {
|
||||
runtimeSessionId: 'nova',
|
||||
};
|
||||
|
||||
describe('TessDurableSessionRepository', () => {
|
||||
describe('DurableSessionRepository', () => {
|
||||
let dataDir: string | undefined;
|
||||
let handle: DbHandle;
|
||||
let previousAuthSecret: string | undefined;
|
||||
@@ -48,9 +48,7 @@ describe('TessDurableSessionRepository', () => {
|
||||
});
|
||||
|
||||
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
||||
const beforeRestart = new DurableSessionCoordinator(
|
||||
new TessDurableSessionRepository(handle.db),
|
||||
);
|
||||
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await beforeRestart.create(IDENTITY);
|
||||
await beforeRestart.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
@@ -92,7 +90,7 @@ describe('TessDurableSessionRepository', () => {
|
||||
await handle.close();
|
||||
handle = createPgliteDb(dataDir!);
|
||||
|
||||
const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
||||
const handled: string[] = [];
|
||||
@@ -120,7 +118,7 @@ describe('TessDurableSessionRepository', () => {
|
||||
}, 30_000);
|
||||
|
||||
it('redacts sensitive durable payloads before persistence', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
@@ -157,7 +155,7 @@ describe('TessDurableSessionRepository', () => {
|
||||
}, 30_000);
|
||||
|
||||
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||
delete process.env['BETTER_AUTH_SECRET'];
|
||||
@@ -177,7 +175,7 @@ describe('TessDurableSessionRepository', () => {
|
||||
}, 30_000);
|
||||
|
||||
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
const input = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
@@ -227,7 +225,7 @@ describe('TessDurableSessionRepository', () => {
|
||||
}, 30_000);
|
||||
|
||||
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
const inbox = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
@@ -255,7 +253,7 @@ describe('TessDurableSessionRepository', () => {
|
||||
}, 30_000);
|
||||
|
||||
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
@@ -293,10 +291,10 @@ describe('TessDurableSessionRepository', () => {
|
||||
}, 30_000);
|
||||
|
||||
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
||||
const repository = new TessDurableSessionRepository(handle.db);
|
||||
const repository = new DurableSessionRepository(handle.db);
|
||||
const coordinator = new DurableSessionCoordinator(repository);
|
||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||
const input = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'live-effect',
|
||||
@@ -333,10 +331,10 @@ describe('TessDurableSessionRepository', () => {
|
||||
}, 30_000);
|
||||
|
||||
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
||||
const repository = new TessDurableSessionRepository(handle.db);
|
||||
const repository = new DurableSessionRepository(handle.db);
|
||||
const coordinator = new DurableSessionCoordinator(repository);
|
||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||
const input = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'mismatch-effect',
|
||||
@@ -366,10 +364,10 @@ describe('TessDurableSessionRepository', () => {
|
||||
}, 30_000);
|
||||
|
||||
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
||||
const repository = new TessDurableSessionRepository(handle.db);
|
||||
const repository = new DurableSessionRepository(handle.db);
|
||||
const coordinator = new DurableSessionCoordinator(repository);
|
||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||
const first = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'scoped-effect-one',
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
export class TessDurableSessionRepository implements DurableSessionStore {
|
||||
export class DurableSessionRepository implements DurableSessionStore {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||
@@ -51,7 +51,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
||||
|
||||
const existing = await this.session(identity.sessionId);
|
||||
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
||||
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
||||
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
||||
}
|
||||
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
||||
// the conversation handle and owner scope remain immutable.
|
||||
@@ -135,13 +135,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new Error(`Durable Tess inbox enqueue failed: ${input.idempotencyKey}`);
|
||||
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
|
||||
const entry = toInbox(existing[0]);
|
||||
if (
|
||||
!sameInbox(entry, record) ||
|
||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||
) {
|
||||
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: entry.status };
|
||||
}
|
||||
@@ -224,14 +224,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing[0])
|
||||
throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`);
|
||||
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
|
||||
const entry = toOutbox(existing[0]);
|
||||
if (
|
||||
!sameOutbox(entry, record) ||
|
||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||
) {
|
||||
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: entry.status };
|
||||
}
|
||||
@@ -338,7 +337,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
||||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
||||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
||||
) {
|
||||
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
||||
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +359,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
||||
if (!checkpoint) {
|
||||
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||
}
|
||||
const inserted = await this.db
|
||||
.insert(interactionHandoffs)
|
||||
@@ -371,7 +370,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
||||
|
||||
const existing = await this.findHandoff(input.handoffId);
|
||||
if (!existing || !sameHandoff(existing, input)) {
|
||||
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
||||
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||
import type { ProviderOutboxDto } from './durable-session.dto.js';
|
||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||
import {
|
||||
RuntimeProviderService,
|
||||
type RuntimeProviderRequestContext,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
|
||||
/**
|
||||
* Scoped gateway boundary for the canonical Tess state machine. It deliberately
|
||||
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
|
||||
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
||||
* MCP adapters without a server-derived actor/tenant/correlation context.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TessDurableSessionService {
|
||||
export class DurableSessionService {
|
||||
private readonly coordinator: DurableSessionCoordinator;
|
||||
|
||||
constructor(
|
||||
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
|
||||
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
|
||||
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
||||
) {
|
||||
this.coordinator = new DurableSessionCoordinator(repository);
|
||||
@@ -32,12 +32,12 @@ export class TessDurableSessionService {
|
||||
identity.ownerId !== context.actorScope.userId ||
|
||||
identity.tenantId !== context.actorScope.tenantId
|
||||
) {
|
||||
throw new ForbiddenException('Durable Tess enrollment scope mismatch');
|
||||
throw new ForbiddenException('Durable session enrollment scope mismatch');
|
||||
}
|
||||
await this.coordinator.create(identity);
|
||||
}
|
||||
|
||||
async queueProviderSend(input: TessProviderOutboxDto): Promise<void> {
|
||||
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
|
||||
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||
await this.coordinator.enqueueOutbox({
|
||||
@@ -50,9 +50,9 @@ export class TessDurableSessionService {
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
||||
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
||||
if (sessionId !== input.sessionId) {
|
||||
throw new ForbiddenException('Durable Tess outbox session mismatch');
|
||||
throw new ForbiddenException('Durable outbox session mismatch');
|
||||
}
|
||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||
@@ -92,7 +92,7 @@ export class TessDurableSessionService {
|
||||
}
|
||||
|
||||
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
||||
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
||||
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||
await this.coordinator.recover(sessionId);
|
||||
@@ -100,24 +100,24 @@ export class TessDurableSessionService {
|
||||
|
||||
private assertOutboxScope(
|
||||
entry: { kind: string; correlationId: string; channelId: string },
|
||||
input: TessProviderOutboxDto,
|
||||
input: ProviderOutboxDto,
|
||||
): void {
|
||||
if (
|
||||
entry.kind !== 'provider.send' ||
|
||||
entry.correlationId !== input.correlationId ||
|
||||
entry.channelId !== input.context.channelId
|
||||
) {
|
||||
throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch');
|
||||
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void {
|
||||
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
|
||||
if (
|
||||
input.context.actorScope.userId !== ownerId ||
|
||||
input.context.actorScope.tenantId !== tenantId ||
|
||||
input.context.correlationId !== input.correlationId
|
||||
) {
|
||||
throw new ForbiddenException('Durable Tess session scope or correlation mismatch');
|
||||
throw new ForbiddenException('Durable session scope or correlation mismatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||
RuntimeProviderAuditService,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||
import { DurableSessionService } from './durable-session.service.js';
|
||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||
import { AgentService } from './agent.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||
@@ -88,9 +88,9 @@ describe('Hermes runtime provider reachability', (): void => {
|
||||
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
||||
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
||||
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
||||
.overrideProvider(TessDurableSessionService)
|
||||
.overrideProvider(DurableSessionService)
|
||||
.useValue({})
|
||||
.overrideProvider(TessDurableSessionRepository)
|
||||
.overrideProvider(DurableSessionRepository)
|
||||
.useValue({})
|
||||
.overrideProvider(AgentService)
|
||||
.useValue({})
|
||||
@@ -115,6 +115,38 @@ describe('Hermes runtime provider reachability', (): void => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise<void> => {
|
||||
if (!app) throw new Error('Nest application did not initialize');
|
||||
|
||||
const attachDenied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/interaction/Nova/sessions/session-1/attach',
|
||||
headers: { 'x-correlation-id': 'correlation-1' },
|
||||
payload: { mode: 'read' },
|
||||
});
|
||||
expect(attachDenied.statusCode).toBe(401);
|
||||
|
||||
const sendDenied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/interaction/Nova/sessions/session-1/send',
|
||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
||||
payload: {},
|
||||
});
|
||||
expect(sendDenied.statusCode).toBe(403);
|
||||
expect(sendDenied.json()).toMatchObject({
|
||||
message: 'Content and idempotency key are required',
|
||||
});
|
||||
|
||||
const stopDenied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/interaction/Nova/sessions/session-1/stop',
|
||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
||||
payload: {},
|
||||
});
|
||||
expect(stopDenied.statusCode).toBe(403);
|
||||
expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' });
|
||||
});
|
||||
|
||||
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
|
||||
if (!app) throw new Error('Nest application did not initialize');
|
||||
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Observable } from 'rxjs';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||
import { DurableSessionService } from './durable-session.service.js';
|
||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||
import {
|
||||
RuntimeProviderService,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
export class InteractionController {
|
||||
constructor(
|
||||
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
||||
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
|
||||
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
|
||||
) {}
|
||||
|
||||
@Get('sessions')
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
RuntimeProviderService,
|
||||
type RuntimeAuditSink,
|
||||
} from '../agent/runtime-provider-registry.service.js';
|
||||
import { TessDurableSessionService } from '../agent/tess-durable-session.service.js';
|
||||
import { DurableSessionService } from '../agent/durable-session.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import {
|
||||
scopeFromUser,
|
||||
@@ -140,8 +140,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@Inject(RuntimeProviderService)
|
||||
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||
@Optional()
|
||||
@Inject(TessDurableSessionService)
|
||||
private readonly durableSessions: TessDurableSessionService | null = null,
|
||||
@Inject(DurableSessionService)
|
||||
private readonly durableSessions: DurableSessionService | null = null,
|
||||
@Optional()
|
||||
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryMosCoordinationPort } from '@mosaicstack/coord';
|
||||
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
|
||||
import { CoordService } from './coord.service.js';
|
||||
import { CoordController } from './coord.controller.js';
|
||||
import { MosCoordinationController } from './mos-coordination.controller.js';
|
||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||
import {
|
||||
MOS_COORDINATION_CONFIG,
|
||||
MOS_COORDINATION_PORT,
|
||||
MosCoordinationService,
|
||||
} from './mos-coordination.service.js';
|
||||
COORDINATION_CONFIG,
|
||||
COORDINATION_PORT,
|
||||
InteractionCoordinationService,
|
||||
} from './interaction-coordination.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
CoordService,
|
||||
{
|
||||
provide: MOS_COORDINATION_PORT,
|
||||
useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(),
|
||||
provide: COORDINATION_PORT,
|
||||
useFactory: (): InMemoryInteractionCoordinationPort =>
|
||||
new InMemoryInteractionCoordinationPort(),
|
||||
},
|
||||
{
|
||||
provide: MOS_COORDINATION_CONFIG,
|
||||
provide: COORDINATION_CONFIG,
|
||||
useFactory: () => ({
|
||||
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
||||
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
||||
}),
|
||||
},
|
||||
MosCoordinationService,
|
||||
InteractionCoordinationService,
|
||||
],
|
||||
controllers: [CoordController, MosCoordinationController],
|
||||
exports: [CoordService, MosCoordinationService],
|
||||
controllers: [CoordController, InteractionCoordinationController],
|
||||
exports: [CoordService, InteractionCoordinationService],
|
||||
})
|
||||
export class CoordModule {}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
const PATH_METADATA = 'path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MosCoordinationController } from './mos-coordination.controller.js';
|
||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||
|
||||
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
||||
|
||||
describe('MosCoordinationController', () => {
|
||||
describe('InteractionCoordinationController', () => {
|
||||
it('exposes the neutral canonical route and Mos compatibility alias over identical handlers', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, InteractionCoordinationController)).toEqual([
|
||||
'api/coord/interaction',
|
||||
'api/coord/mos',
|
||||
]);
|
||||
expect(InteractionCoordinationController.prototype.handoff).toBeTypeOf('function');
|
||||
expect(InteractionCoordinationController.prototype.observe).toBeTypeOf('function');
|
||||
expect(InteractionCoordinationController.prototype.result).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
|
||||
const coordination = {
|
||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||
observe: vi.fn(),
|
||||
result: vi.fn(),
|
||||
};
|
||||
const controller = new MosCoordinationController(coordination as never);
|
||||
const controller = new InteractionCoordinationController(coordination as never);
|
||||
|
||||
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
|
||||
|
||||
@@ -26,7 +37,7 @@ describe('MosCoordinationController', () => {
|
||||
|
||||
it('requires a correlation header before invoking the coordination service', async () => {
|
||||
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
|
||||
const controller = new MosCoordinationController(coordination as never);
|
||||
const controller = new InteractionCoordinationController(coordination as never);
|
||||
|
||||
await expect(
|
||||
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
|
||||
@@ -14,27 +14,29 @@ import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||
import type {
|
||||
MosCoordinationObservationDto,
|
||||
MosCoordinationResponseDto,
|
||||
MosCoordinationResultDto,
|
||||
CreateMosHandoffDto,
|
||||
} from './mos-coordination.dto.js';
|
||||
import { MosCoordinationService } from './mos-coordination.service.js';
|
||||
InteractionCoordinationObservationDto,
|
||||
InteractionCoordinationResponseDto,
|
||||
InteractionCoordinationResultDto,
|
||||
CreateHandoffDto,
|
||||
} from './interaction-coordination.dto.js';
|
||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||
|
||||
/** Authenticated interaction-plane boundary for the handoff/observe/result-only Mos contract. */
|
||||
@Controller('api/coord/mos')
|
||||
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
|
||||
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
|
||||
@Controller(['api/coord/interaction', 'api/coord/mos'])
|
||||
@UseGuards(AuthGuard)
|
||||
export class MosCoordinationController {
|
||||
export class InteractionCoordinationController {
|
||||
constructor(
|
||||
@Inject(MosCoordinationService) private readonly coordination: MosCoordinationService,
|
||||
@Inject(InteractionCoordinationService)
|
||||
private readonly coordination: InteractionCoordinationService,
|
||||
) {}
|
||||
|
||||
@Post('handoff')
|
||||
async handoff(
|
||||
@Body() request: CreateMosHandoffDto,
|
||||
@Body() request: CreateHandoffDto,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Promise<MosCoordinationResponseDto> {
|
||||
): Promise<InteractionCoordinationResponseDto> {
|
||||
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
||||
}
|
||||
|
||||
@@ -43,7 +45,7 @@ export class MosCoordinationController {
|
||||
@Param('handoffId') handoffId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Promise<MosCoordinationObservationDto> {
|
||||
): Promise<InteractionCoordinationObservationDto> {
|
||||
return {
|
||||
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
||||
};
|
||||
@@ -54,7 +56,7 @@ export class MosCoordinationController {
|
||||
@Param('handoffId') handoffId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Promise<MosCoordinationResultDto> {
|
||||
): Promise<InteractionCoordinationResultDto> {
|
||||
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type {
|
||||
CoordinationObservation,
|
||||
CoordinationResult,
|
||||
MosHandoffReceipt,
|
||||
HandoffReceipt,
|
||||
} from '@mosaicstack/coord';
|
||||
|
||||
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
||||
export interface CreateMosHandoffDto {
|
||||
export interface CreateHandoffDto {
|
||||
idempotencyKey: string;
|
||||
summary: string;
|
||||
context?: string;
|
||||
missionId?: string;
|
||||
}
|
||||
|
||||
export interface MosCoordinationResponseDto {
|
||||
receipt: MosHandoffReceipt;
|
||||
export interface InteractionCoordinationResponseDto {
|
||||
receipt: HandoffReceipt;
|
||||
}
|
||||
|
||||
export interface MosCoordinationObservationDto {
|
||||
export interface InteractionCoordinationObservationDto {
|
||||
observation: CoordinationObservation;
|
||||
}
|
||||
|
||||
export interface MosCoordinationResultDto {
|
||||
export interface InteractionCoordinationResultDto {
|
||||
result: CoordinationResult;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: AUTH,
|
||||
useValue: {
|
||||
api: {
|
||||
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
|
||||
headers.get('cookie') === 'session=trusted'
|
||||
? { user: { id: 'operator-1', tenantId: 'tenant-1' }, session: { id: 'session-1' } }
|
||||
: null,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
AuthGuard,
|
||||
],
|
||||
exports: [AUTH, AuthGuard],
|
||||
})
|
||||
class AuthenticatedRequestModule {}
|
||||
|
||||
describe('InteractionCoordinationController route aliases', (): void => {
|
||||
let app: NestFastifyApplication | undefined;
|
||||
const coordination = {
|
||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||
observe: vi.fn(async () => ({ status: 'running' })),
|
||||
result: vi.fn(async () => ({ status: 'completed' })),
|
||||
};
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AuthenticatedRequestModule],
|
||||
controllers: [InteractionCoordinationController],
|
||||
providers: [{ provide: InteractionCoordinationService, useValue: coordination }],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
afterAll(async (): Promise<void> => app?.close());
|
||||
|
||||
it('routes handoff, observe, and result through the same AuthGuard-protected service for both prefixes', async (): Promise<void> => {
|
||||
if (!app) throw new Error('test app was not initialized');
|
||||
for (const prefix of ['/api/coord/interaction', '/api/coord/mos']) {
|
||||
const headers = { cookie: 'session=trusted', 'x-correlation-id': `corr-${prefix}` };
|
||||
expect(
|
||||
(
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `${prefix}/handoff`,
|
||||
headers,
|
||||
payload: { idempotencyKey: `key-${prefix}`, summary: 'handoff' },
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/observe`, headers }))
|
||||
.statusCode,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/result`, headers }))
|
||||
.statusCode,
|
||||
).toBe(200);
|
||||
}
|
||||
expect(coordination.handoff).toHaveBeenCalledTimes(2);
|
||||
expect(coordination.observe).toHaveBeenCalledTimes(2);
|
||||
expect(coordination.result).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
(
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/coord/interaction/handoff',
|
||||
payload: { idempotencyKey: 'denied', summary: 'x' },
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
InMemoryMosCoordinationPort,
|
||||
type MosCoordinationPort,
|
||||
type MosHandoff,
|
||||
InMemoryInteractionCoordinationPort,
|
||||
type InteractionCoordinationPort,
|
||||
type Handoff,
|
||||
} from '@mosaicstack/coord';
|
||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||
import {
|
||||
MosCoordinationService,
|
||||
type MosCoordinationConfig,
|
||||
type MosCoordinationGatewayError,
|
||||
} from './mos-coordination.service.js';
|
||||
InteractionCoordinationService,
|
||||
type InteractionCoordinationConfig,
|
||||
type InteractionCoordinationGatewayError,
|
||||
} from './interaction-coordination.service.js';
|
||||
|
||||
const context: RuntimeProviderRequestContext = {
|
||||
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||
@@ -17,28 +17,28 @@ const context: RuntimeProviderRequestContext = {
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
|
||||
const config: MosCoordinationConfig = {
|
||||
const config: InteractionCoordinationConfig = {
|
||||
interactionAgentId: 'Nova',
|
||||
orchestrationAgentId: 'Conductor',
|
||||
};
|
||||
|
||||
function service(
|
||||
port: MosCoordinationPort = new InMemoryMosCoordinationPort(),
|
||||
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
|
||||
options: {
|
||||
config?: MosCoordinationConfig;
|
||||
config?: InteractionCoordinationConfig;
|
||||
handoffIdFactory?: () => string;
|
||||
} = {},
|
||||
): MosCoordinationService {
|
||||
return new MosCoordinationService(
|
||||
): InteractionCoordinationService {
|
||||
return new InteractionCoordinationService(
|
||||
port,
|
||||
options.config ?? config,
|
||||
options.handoffIdFactory ?? (() => 'handoff-1'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('MosCoordinationService authority boundary', (): void => {
|
||||
describe('InteractionCoordinationService authority boundary', (): void => {
|
||||
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const coordination = service(adapter);
|
||||
|
||||
await expect(
|
||||
@@ -53,8 +53,8 @@ describe('MosCoordinationService authority boundary', (): void => {
|
||||
correlationId: 'corr-1',
|
||||
});
|
||||
|
||||
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
||||
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
||||
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
|
||||
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
||||
|
||||
const followUpContext = { ...context, correlationId: 'corr-2' };
|
||||
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||
@@ -64,12 +64,12 @@ describe('MosCoordinationService authority boundary', (): void => {
|
||||
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||
targetAgentId: 'Conductor',
|
||||
status: 'completed',
|
||||
summary: 'Merged by Mos',
|
||||
summary: 'Merged by orchestrator',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const handoff = vi.spyOn(adapter, 'handoff');
|
||||
const coordination = service(adapter, {
|
||||
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
||||
@@ -82,12 +82,12 @@ describe('MosCoordinationService authority boundary', (): void => {
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'unconfigured_requester',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
expect(handoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const handoff = vi.spyOn(adapter, 'handoff');
|
||||
const coordination = service(adapter, {
|
||||
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||
@@ -103,7 +103,7 @@ describe('MosCoordinationService authority boundary', (): void => {
|
||||
});
|
||||
|
||||
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const observe = vi.spyOn(adapter, 'observe');
|
||||
const result = vi.spyOn(adapter, 'result');
|
||||
const coordination = service(adapter);
|
||||
@@ -118,10 +118,10 @@ describe('MosCoordinationService authority boundary', (): void => {
|
||||
};
|
||||
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||
code: 'cross_tenant_forbidden',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||
code: 'cross_tenant_forbidden',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
expect(observe).not.toHaveBeenCalled();
|
||||
expect(result).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -132,8 +132,8 @@ describe('MosCoordinationService authority boundary', (): void => {
|
||||
const delivered = new Promise<void>((resolve: () => void): void => {
|
||||
release = resolve;
|
||||
});
|
||||
const adapter: MosCoordinationPort = {
|
||||
handoff: vi.fn(async (handoff: MosHandoff) => {
|
||||
const adapter: InteractionCoordinationPort = {
|
||||
handoff: vi.fn(async (handoff: Handoff) => {
|
||||
await delivered;
|
||||
return {
|
||||
handoffId: handoff.handoffId,
|
||||
@@ -169,7 +169,7 @@ describe('MosCoordinationService authority boundary', (): void => {
|
||||
});
|
||||
|
||||
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const handoff = vi.spyOn(adapter, 'handoff');
|
||||
const coordination = service(adapter);
|
||||
await coordination.handoff(
|
||||
@@ -181,23 +181,23 @@ describe('MosCoordinationService authority boundary', (): void => {
|
||||
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
||||
).rejects.toMatchObject({
|
||||
code: 'handoff_conflict',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
await expect(
|
||||
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_request',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
await expect(
|
||||
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_request',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||
expect(handoff).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
||||
const adapter: MosCoordinationPort = {
|
||||
handoff: vi.fn(async (handoff: MosHandoff) => ({
|
||||
const adapter: InteractionCoordinationPort = {
|
||||
handoff: vi.fn(async (handoff: Handoff) => ({
|
||||
handoffId: handoff.handoffId,
|
||||
targetAgentId: 'Unexpected',
|
||||
status: 'accepted' as const,
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
MosCoordinationClient,
|
||||
InteractionCoordinationClient,
|
||||
type CoordinationObservation,
|
||||
type CoordinationResult,
|
||||
type CoordinationScope,
|
||||
type MosCoordinationIdentity,
|
||||
type MosCoordinationPort,
|
||||
type MosHandoffReceipt,
|
||||
type InteractionCoordinationIdentity,
|
||||
type InteractionCoordinationPort,
|
||||
type HandoffReceipt,
|
||||
} from '@mosaicstack/coord';
|
||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||
import type { CreateMosHandoffDto } from './mos-coordination.dto.js';
|
||||
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
||||
|
||||
export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT');
|
||||
export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG');
|
||||
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||
|
||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||
@@ -21,7 +21,7 @@ const MAX_SUMMARY_LENGTH = 2_048;
|
||||
const MAX_CONTEXT_LENGTH = 8_192;
|
||||
const MAX_MISSION_ID_LENGTH = 128;
|
||||
|
||||
export interface MosCoordinationConfig {
|
||||
export interface InteractionCoordinationConfig {
|
||||
interactionAgentId?: string;
|
||||
orchestrationAgentId?: string;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ interface HandoffOwner {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface NormalizedMosHandoffRequest {
|
||||
interface NormalizedHandoffRequest {
|
||||
idempotencyKey: string;
|
||||
summary: string;
|
||||
context?: string;
|
||||
@@ -42,31 +42,31 @@ interface NormalizedMosHandoffRequest {
|
||||
}
|
||||
|
||||
interface TrackedHandoff {
|
||||
request: NormalizedMosHandoffRequest;
|
||||
receipt: Promise<MosHandoffReceipt>;
|
||||
request: NormalizedHandoffRequest;
|
||||
receipt: Promise<HandoffReceipt>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway authority boundary for the interaction agent. It derives requester,
|
||||
* actor, and tenant from trusted server configuration and authentication; no
|
||||
* channel request can name a target or gain Mos-owned orchestration verbs.
|
||||
* channel request can name a target or gain orchestrator-owned orchestration verbs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MosCoordinationService {
|
||||
export class InteractionCoordinationService {
|
||||
private readonly owners = new Map<string, HandoffOwner>();
|
||||
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
||||
|
||||
constructor(
|
||||
@Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort,
|
||||
@Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig,
|
||||
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||
) {}
|
||||
|
||||
async handoff(
|
||||
request: CreateMosHandoffDto,
|
||||
request: CreateHandoffDto,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<MosHandoffReceipt> {
|
||||
): Promise<HandoffReceipt> {
|
||||
this.pruneExpiredTracking();
|
||||
const normalized = this.normalizeRequest(request);
|
||||
const scope = this.scope(context);
|
||||
@@ -74,9 +74,9 @@ export class MosCoordinationService {
|
||||
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
||||
if (existing !== undefined) {
|
||||
if (!sameRequest(existing.request, normalized)) {
|
||||
throw new MosCoordinationGatewayError(
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'handoff_conflict',
|
||||
'Mos handoff idempotency key is already bound to different immutable input',
|
||||
'Handoff idempotency key is already bound to different immutable input',
|
||||
);
|
||||
}
|
||||
return existing.receipt;
|
||||
@@ -122,9 +122,9 @@ export class MosCoordinationService {
|
||||
|
||||
private async deliverHandoff(
|
||||
handoffId: string,
|
||||
request: NormalizedMosHandoffRequest,
|
||||
request: NormalizedHandoffRequest,
|
||||
scope: CoordinationScope,
|
||||
): Promise<MosHandoffReceipt> {
|
||||
): Promise<HandoffReceipt> {
|
||||
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
||||
const owner: HandoffOwner = {
|
||||
actorId: scope.actorId,
|
||||
@@ -135,9 +135,9 @@ export class MosCoordinationService {
|
||||
};
|
||||
const existing = this.owners.get(receipt.handoffId);
|
||||
if (existing !== undefined && !sameOwner(existing, owner)) {
|
||||
throw new MosCoordinationGatewayError(
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'handoff_conflict',
|
||||
'Mos handoff ID is already bound to a different authenticated scope',
|
||||
'Handoff ID is already bound to a different authenticated scope',
|
||||
);
|
||||
}
|
||||
this.owners.set(receipt.handoffId, owner);
|
||||
@@ -145,21 +145,21 @@ export class MosCoordinationService {
|
||||
return receipt;
|
||||
}
|
||||
|
||||
private client(handoffIdFactory?: () => string): MosCoordinationClient {
|
||||
return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
||||
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
|
||||
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
||||
}
|
||||
|
||||
private identity(): MosCoordinationIdentity {
|
||||
private identity(): InteractionCoordinationIdentity {
|
||||
const interactionAgentId = this.config.interactionAgentId?.trim();
|
||||
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
||||
if (!interactionAgentId) {
|
||||
throw new MosCoordinationGatewayError(
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'unconfigured_requester',
|
||||
'Interaction agent identity is not configured',
|
||||
);
|
||||
}
|
||||
if (!orchestrationAgentId) {
|
||||
throw new MosCoordinationGatewayError(
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'unconfigured_target',
|
||||
'Orchestration agent identity is not configured',
|
||||
);
|
||||
@@ -177,9 +177,12 @@ export class MosCoordinationService {
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest {
|
||||
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
|
||||
if (typeof request !== 'object' || request === null) {
|
||||
throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid');
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'invalid_request',
|
||||
'Handoff request is invalid',
|
||||
);
|
||||
}
|
||||
const idempotencyKey = this.requiredString(
|
||||
request.idempotencyKey,
|
||||
@@ -203,14 +206,17 @@ export class MosCoordinationService {
|
||||
|
||||
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new MosCoordinationGatewayError(
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'invalid_request',
|
||||
`Mos handoff ${field} must be a string`,
|
||||
`Handoff ${field} must be a string`,
|
||||
);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0 || normalized.length > maximumLength) {
|
||||
throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`);
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'invalid_request',
|
||||
`Handoff ${field} is invalid`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -245,23 +251,23 @@ export class MosCoordinationService {
|
||||
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
||||
const owner = this.owners.get(handoffId);
|
||||
if (owner === undefined) {
|
||||
throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found');
|
||||
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
|
||||
}
|
||||
if (
|
||||
owner.tenantId !== scope.tenantId ||
|
||||
owner.actorId !== scope.actorId ||
|
||||
owner.requesterAgentId !== scope.requesterAgentId
|
||||
) {
|
||||
throw new MosCoordinationGatewayError(
|
||||
throw new InteractionCoordinationGatewayError(
|
||||
'cross_tenant_forbidden',
|
||||
'Mos handoff is outside the authenticated scope',
|
||||
'Handoff is outside the authenticated scope',
|
||||
);
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
}
|
||||
|
||||
export type MosCoordinationGatewayErrorCode =
|
||||
export type InteractionCoordinationGatewayErrorCode =
|
||||
| 'cross_tenant_forbidden'
|
||||
| 'handoff_conflict'
|
||||
| 'invalid_request'
|
||||
@@ -277,10 +283,7 @@ function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function sameRequest(
|
||||
left: NormalizedMosHandoffRequest,
|
||||
right: NormalizedMosHandoffRequest,
|
||||
): boolean {
|
||||
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
|
||||
return (
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.summary === right.summary &&
|
||||
@@ -289,12 +292,12 @@ function sameRequest(
|
||||
);
|
||||
}
|
||||
|
||||
export class MosCoordinationGatewayError extends Error {
|
||||
export class InteractionCoordinationGatewayError extends Error {
|
||||
constructor(
|
||||
readonly code: MosCoordinationGatewayErrorCode,
|
||||
readonly code: InteractionCoordinationGatewayErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = MosCoordinationGatewayError.name;
|
||||
this.name = InteractionCoordinationGatewayError.name;
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@
|
||||
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
||||
**Phase:** Execution (workstream W1 in planning-complete state)
|
||||
**Current Workstream:** W1 — Federation v1
|
||||
**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined)
|
||||
**Progress:** 0 / 3 declared workstreams complete (more workstreams will be declared as scope is refined)
|
||||
**Status:** active (continuous since 2026-03-13)
|
||||
**Last Updated:** 2026-04-19 (manifest authored at the rollup level; install-ux-v2 archived; W1 federation planning landed via PR #468)
|
||||
**Last Updated:** 2026-07-14 (W3 Native Kanban/SOT canon independently approved under issue #751)
|
||||
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
||||
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
||||
|
||||
@@ -67,11 +67,12 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
||||
|
||||
## Workstreams
|
||||
|
||||
| # | ID | Name | Status | Manifest | Notes |
|
||||
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
||||
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||
| # | ID | Name | Status | Manifest | Notes |
|
||||
| --- | ---- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
||||
| W3 | KBN | Native Kanban and canonical task SOT | planning-complete | [docs/native-kanban-sot/MISSION-MANIFEST.md](./native-kanban-sot/MISSION-MANIFEST.md) | P0–P3; issue #751; implementation held until canon merge |
|
||||
| W4+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||
|
||||
### Likely Additional Workstreams (Not Yet Declared)
|
||||
|
||||
|
||||
43
docs/SITEMAP.md
Normal file
43
docs/SITEMAP.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Documentation Sitemap
|
||||
|
||||
## Native Kanban and canonical task SOT
|
||||
|
||||
- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0–P3 requirements and acceptance criteria.
|
||||
- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order.
|
||||
- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model.
|
||||
- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries.
|
||||
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
|
||||
- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001–016 findings that blocked the first draft.
|
||||
- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict.
|
||||
- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review.
|
||||
|
||||
## Tess interaction agent
|
||||
|
||||
### Operator guides
|
||||
|
||||
- [User guide](tess/USER-GUIDE.md) — authorized session, attach, send, stop, and handoff workflows.
|
||||
- [Admin guide](tess/ADMIN-GUIDE.md) — deployment configuration, policy, and approval controls.
|
||||
- [Developer guide](tess/DEVELOPER-GUIDE.md) — provider contracts, scope boundaries, and test workflow.
|
||||
- [Plugin guide](tess/PLUGIN-GUIDE.md) — adapter, redaction, and identity-as-data requirements.
|
||||
- [Operations guide](tess/OPERATIONS-GUIDE.md) — readiness, recovery, and incident-safe procedures.
|
||||
|
||||
### Architecture and security
|
||||
|
||||
- [Architecture](tess/ARCHITECTURE.md)
|
||||
- [Threat model](tess/THREAT-MODEL.md)
|
||||
- [Mos coordination boundary](tess/MOS-COORDINATION.md)
|
||||
- [Hermes runtime adapter design](tess/hermes-runtime-adapter-design.md)
|
||||
- [Operator plugin sketch](tess/M4-003-OPERATOR-PLUGIN-SKETCH.md)
|
||||
|
||||
### API contract
|
||||
|
||||
- [Tess OpenAPI contract](openapi-tess.yaml)
|
||||
|
||||
### Migration and qualification
|
||||
|
||||
- [Migration inventory](tess/M5-MIGRATION-INVENTORY.md)
|
||||
- [Cutover procedure](tess/M5-MIGRATION-CUTOVER.md)
|
||||
- [Rollback procedure](tess/M5-MIGRATION-ROLLBACK.md)
|
||||
- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md)
|
||||
- [Verification matrix](tess/VERIFICATION-MATRIX.md)
|
||||
- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md)
|
||||
@@ -14,10 +14,11 @@
|
||||
|
||||
## Workstream Rollup
|
||||
|
||||
| id | status | workstream | progress | tasks file | notes |
|
||||
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
||||
| id | status | workstream | progress | tasks file | notes |
|
||||
| --- | ----------------- | ---------------------- | ---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
||||
| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges |
|
||||
|
||||
## Cross-Cutting Tracking
|
||||
|
||||
|
||||
36
docs/native-kanban-sot/DOCUMENTATION-CHECKLIST.md
Normal file
36
docs/native-kanban-sot/DOCUMENTATION-CHECKLIST.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Documentation Completion Checklist — Native Kanban/SOT Canon
|
||||
|
||||
**Tracking:** Mosaic Stack issue #751
|
||||
**Scope:** Requirements and contract publication only; runtime implementation follows in separate slices.
|
||||
|
||||
## Required artifacts
|
||||
|
||||
- [x] Project `docs/PRD.md` exists; the workstream requirements refine its task/project-management scope.
|
||||
- [x] Canonical workstream requirements published at `docs/requirements/native-kanban-sot.md`.
|
||||
- [x] Mission manifest, task decomposition, frozen shared contract, and typed contract declarations included.
|
||||
- [x] `docs/SITEMAP.md` updated.
|
||||
- [x] Independent initial review and final GO report stored under `docs/reports/native-kanban-sot/`.
|
||||
- [x] Task scratchpad stored under `docs/scratchpads/`.
|
||||
- [ ] User/Admin/Developer guides — N/A for canon-only publication; required in implementation slices that change behavior or operations.
|
||||
- [ ] OpenAPI and endpoint index — N/A until KBN-105 freezes implementation-ready endpoint contracts.
|
||||
|
||||
## Structural and root hygiene
|
||||
|
||||
- [x] Canonical requirements are under `docs/requirements/`.
|
||||
- [x] Workstream artifacts are under `docs/native-kanban-sot/`.
|
||||
- [x] Review reports are under `docs/reports/native-kanban-sot/`.
|
||||
- [x] No new unscoped document was added to the `docs/` root.
|
||||
- [x] Root mission/task rollups link to the workstream.
|
||||
|
||||
## Review gate
|
||||
|
||||
- [x] Author and independent reviewer are different agents.
|
||||
- [x] KCR-001–016 closure was independently verified.
|
||||
- [x] Ultron final gate returned GO with zero BLOCKER/HIGH findings.
|
||||
- [x] Formatter, lint, typecheck, strict contract TypeScript, link, scope, and invariant publication validation passed in the current Stack toolchain.
|
||||
- [ ] PR review, CI, squash merge, and issue closure remain required before publication completion.
|
||||
|
||||
## Publishing
|
||||
|
||||
- [x] Canonical source remains in-repository.
|
||||
- [x] No external publishing platform is required for this internal architecture contract.
|
||||
64
docs/native-kanban-sot/INDEX.md
Normal file
64
docs/native-kanban-sot/INDEX.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Native Kanban/SOT Canon
|
||||
|
||||
**Status:** KCR-001–016 independently cleared; canonical publication is in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
**Date:** 2026-07-14
|
||||
**Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied.
|
||||
|
||||
## Artifacts
|
||||
|
||||
| Artifact | Purpose |
|
||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0–P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria |
|
||||
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership |
|
||||
| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0–P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization |
|
||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||
| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults |
|
||||
| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations |
|
||||
| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals |
|
||||
| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001–016 findings that blocked the first draft |
|
||||
| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
|
||||
| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
|
||||
|
||||
## Recommended USC lane partition
|
||||
|
||||
| Lane | Natural seam | Exclusive ownership |
|
||||
| ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **coder2** | Schema + migrations + recovery slice | Unified Drizzle schema, migration SQL/meta/journal/tests, then recovery parser/mechanism/runbook files |
|
||||
| **coder3** | Domain + Gateway + MCP server | Workspace-safe repositories, DTOs/controllers/services, exact `apps/gateway/src/mcp/**` files, health proof, proposals, Coordinator persistence adapter |
|
||||
| **coder4** | Pure Coordinator + tooling | `packages/coord` mechanical engine, CLI/MCP consumers, generated projection, one-way importer and cutover tooling; lane-serialized internally |
|
||||
| **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI |
|
||||
| **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority |
|
||||
|
||||
The safe order is KBN-010 → KBN-100 → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files.
|
||||
|
||||
## Recovery defaults
|
||||
|
||||
| Tier | RPO / RTO | WAL / PITR | Base backup | Restore / break-glass | Off-cluster |
|
||||
| -------------- | ------------ | ------------------- | ----------- | ----------------------- | ----------------------------------------------- |
|
||||
| Lite | 24h / 24h | disabled / disabled | daily | quarterly / annual | encrypted separate target |
|
||||
| Standard | 1h / 8h | q15m / 14d | daily | quarterly / semiannual | encrypted separate object storage |
|
||||
| High-assurance | **15m / 4h** | **q5m / 35d** | **daily** | **monthly / quarterly** | **encrypted base+WAL, separate failure domain** |
|
||||
|
||||
These knobs affect recovery posture only. PostgreSQL remains the sole writable SOT in every tier. Fail-closed writes, generated-file non-authority, attributable post-recovery proposals, non-LLM Coordinator limits, and Certifier final-gate/no-merge authority are fixed for every tier.
|
||||
|
||||
## Non-blocking implementation sub-decisions for Mos
|
||||
|
||||
The source plan and ratified seven decisions resolve all build-blocking product choices. The following implementation-local selections remain for the owning slices/Mos and must not weaken v1:
|
||||
|
||||
1. Exact PostgreSQL write-health probe SQL and bounded proof lifetime; authority and failures are frozen.
|
||||
2. Dependency-cycle serialization mechanism (recursive CTE plus transaction/advisory lock or equivalent); required behavior is frozen.
|
||||
3. Whether RLS lands in the first migration or immediately after the tested session-context pattern; workspace constraints/repository authorization are required from migration one.
|
||||
4. Concrete off-cluster backup provider/bucket and selected production recovery tier; High-assurance minima are frozen if selected.
|
||||
5. Cutover reconciliation thresholds and stabilization duration, to be owner-approved before P3 execution.
|
||||
|
||||
None authorizes a second writer, dual sync, LLM scheduling, Coordinator gate waiver/merge, or Certifier merge authority.
|
||||
|
||||
## Publication validation evidence
|
||||
|
||||
- Concrete TypeScript contracts are formatted with repository Prettier.
|
||||
- All four contracts pass strict TypeScript no-emit checking against the current Stack Drizzle toolchain.
|
||||
- Contract remediation and KCR-001–016 traceability are recorded in the issue scratchpad and linked review reports.
|
||||
- Independent re-review returned GO with KCR-001–016 closed; implementation remains held until canon merge and the dependency-ordered KBN prerequisites complete.
|
||||
394
docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md
Normal file
394
docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# KBN-010 — Threat, Authorization, and Constraint-Impact Gate
|
||||
|
||||
- **Issue:** [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753)
|
||||
- **Gate status:** **BLOCKED**
|
||||
- **Reviewed baseline:** `origin/main` at `49e8a54` (2026-07-14)
|
||||
- **Frozen target:** `SHARED-CONTRACT.md` v1.0.0-rc.3 and `contracts/*.v1.ts`
|
||||
- **Scope:** documentation and future-test planning only; no runtime, schema, migration, API, configuration, dependency, CI, or deployment change
|
||||
|
||||
## 1. Decision
|
||||
|
||||
KBN-010 cannot pass. The frozen schema contract has one unresolved schema impact:
|
||||
|
||||
- **KBN010-SI-001 — invalid mission composite-FK candidate key.** `missionsV1` declares a primary key on `id` and a unique key on `(workspace_id, project_id, id)`, but not a candidate key on `(workspace_id, id)`. Both `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` reference exactly `(missions.workspace_id, missions.id)`. PostgreSQL requires the referenced column list of a foreign key to match a non-partial unique/primary candidate key; uniqueness of `id` alone does not satisfy a two-column `(workspace_id, id)` reference. KBN-100 therefore cannot create the frozen DDL as written.
|
||||
|
||||
The contract authority must choose and version one of these semantics before KBN-100 starts:
|
||||
|
||||
1. add a frozen unique candidate key on `missions(workspace_id, id)`; or
|
||||
2. add `project_id` to each child relation and use project-congruent `(workspace_id, project_id, mission_id)` foreign keys.
|
||||
|
||||
This document does **not** choose or apply either amendment. All other findings below are mapped to controls and future evidence, but the gate remains blocked until the frozen contract is amended and independently re-reviewed.
|
||||
|
||||
## 2. Method and trust boundaries
|
||||
|
||||
### 2.1 Inputs inspected
|
||||
|
||||
- Canonical requirements: `docs/requirements/native-kanban-sot.md`.
|
||||
- Workstream manifest and read-only task plan.
|
||||
- Frozen health, schema, Mechanical Coordinator, and recovery contracts in full.
|
||||
- Actual current-main schema, Better Auth guard/scope helpers, project/task/mission/team controllers and repositories, fleet backlog, and `TASKS.md` parser/writer.
|
||||
- Issue #753 through the Mosaic provider wrapper.
|
||||
|
||||
### 2.2 Current-main exposure that the target must replace, not inherit
|
||||
|
||||
| Current-main fact | Constraint on future implementation |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Teams are global; projects, missions, tasks, agents, and fleet backlog have no `workspace_id`. | KBN-100 must add the workspace boundary and KBN-110 must query by server-derived workspace in every repository operation. |
|
||||
| `AuthGuard` authenticates a Better Auth user, while `scopeFromUser` falls back through optional tenant/team/org claims and finally user ID. | Kanban tenancy must derive from an authenticated **active workspace membership**, not this compatibility fallback or caller data. |
|
||||
| Team list/get/member endpoints return global team data to any authenticated user. | New Kanban endpoints must use a uniform no-oracle denial and must not reuse global team lookup as authorization. |
|
||||
| Project/task repositories load and mutate by bare IDs; controller checks are separate and sometimes distinguish not-found from forbidden. | Workspace predicates and authorization must be inside the authoritative transaction/repository command path. |
|
||||
| Tasks can have nullable project/mission links, free-text assignee, JSON tags, no aggregate version, and no fence. | Expand/backfill/quarantine must precede NOT NULL/composite constraints; new commands cannot trust legacy fields. |
|
||||
| `mission_tasks.status` is a second status writer. | Pre-expand must prohibit it as a write source and later retire it only after N-1 evidence. |
|
||||
| Fleet `backlog` has global JSON dependencies and TTL claims without workspace, assignment, approval, session, or fencing. | It must be frozen and imported as non-dispatching shadow data; it cannot be adapted into the canonical lease path. |
|
||||
| `packages/coord/src/tasks-file.ts` parses and mutates `TASKS.md`. | KBN-120 must replace production use with generated, read-only projection code and prove there is no import/mutation path. |
|
||||
| No Kanban transaction-local health proof, semantic audit/event chain, change proposals, canonical outbox, approval binding, or fenced lease model exists. | These are new frozen invariants, not behaviors that may be inferred from current endpoints. |
|
||||
|
||||
## 3. Authorization matrix
|
||||
|
||||
The exact route/DTO freeze belongs to KBN-105. This matrix fixes the minimum authorization behavior that freeze and later implementation must preserve.
|
||||
|
||||
| Principal/state | Permitted authority | Required authoritative checks | Explicit denials |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Unauthenticated caller | Public health observation only, if deployment exposes it | Health DTO validation; no proof field accepted | All canonical reads/mutations; health observation never authorizes a write |
|
||||
| Active workspace `owner`/`admin` user | Policy-allowed workspace administration and domain commands | Better Auth session; active membership; server-derived workspace; command-family role; expected version/idempotency | Foreign workspace, suspended workspace, revoked membership, caller workspace override |
|
||||
| Active workspace `member` user | Policy-allowed project/task/proposal commands | Active membership plus project/team capability and target checks in the same transaction | Admin, approval, purge, service-only Coordinator, and unrelated project commands |
|
||||
| Active workspace `auditor` user | Workspace-scoped reads and audit/evidence inspection | Active membership and read capability | Every mutation, approval, lease, token issuance, purge |
|
||||
| Active workspace `service` identity | Only explicitly issued command families | Credential maps to workspace+agent+session; agent enabled; session live; role/capability allowlist; token expiry/audience; DB recheck per command | Raw DB credentials, user/admin fallback, cross-workspace scope, command families absent from token and registry |
|
||||
| Enabled agent with live session | Agent commands matching its declared and policy-approved specialist role/capabilities | Exact workspace+agent+session binding, heartbeat/state, assignment target, lease, current decimal-string fence | Ended/offline/degraded session where policy disallows; disabled agent; another assignment/session/fence |
|
||||
| Mechanical Coordinator engine | Pure eligibility/order/expiry decisions from immutable snapshots | Complete workspace-local snapshot and policy revision | Authentication, ID loading, SQL, proof minting, scope invention, approval, certification, merge |
|
||||
| Coordinator persistence service | Service-only assignment/lease/checkpoint/recovery commands | Fresh transaction-local proof; locks; current assignment/approval/task/session/policy/fence | Public/user proof-by-value, stale approval/policy, direct completion/certification/merge |
|
||||
| Reviewer/SecReview/Certifier | Attributable evidence decisions allowed by gate policy | Active authority, author differs from reviewer, mandatory SecReview classification, immutable artifacts | Self-review; missing evidence; Certifier merge/issue-close/release |
|
||||
| Break-glass retention operator | Narrow, time-bounded purge procedure only | Separate break-glass authority, reason, scope, approvals, immutable pre-purge evidence, semantic audit, post-action reconciliation | Normal application role DELETE/UPDATE, bulk unscoped purge, unaudited hard delete |
|
||||
| Revoked/expired/disabled identity or ended session | None beyond policy-permitted public observation | Revocation/lifecycle checked from PostgreSQL on every command | Cached token/Valkey state cannot preserve authority |
|
||||
|
||||
**No-oracle rule:** authentication may return 401, but once authenticated, a foreign-workspace, nonexistent, inaccessible, or wrong-project identifier must follow the one KBN-105-frozen 404/403 policy with the same response shape and no foreign metadata, timing-derived detail, or WebSocket/MCP discrepancy.
|
||||
|
||||
## 4. Threat matrix
|
||||
|
||||
Every disposition is against the frozen target, not a claim about current-main behavior.
|
||||
|
||||
| ID | Attacker or failure | Asset | Precondition and abuse path | Frozen preventive/detective control | Required schema/API/negative-test evidence | Future owner | Residual risk | Disposition |
|
||||
| --- | --------------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------- | ----------------------------------- |
|
||||
| T01 | Authenticated user supplies a foreign workspace/resource ID | Tenant confidentiality and integrity | Caller knows or guesses project/task/mission/team IDs and probes REST, MCP, WebSocket, repository, or Coordinator paths | `workspace_id` on every canonical row; composite relations; server-derived tenant; uniform no-oracle denial | Composite FK/unique DDL; every repository predicate includes workspace; N100-01/02, N110-01..05, N130-01 | KBN-100, 105, 110, 130 | Timing/volume side channels require operational review | Controlled after evidence |
|
||||
| T02 | Revoked or inactive member retains an old session | Ownership and mutation authority | Authentication remains valid after workspace membership revocation | Active membership rechecked in the authoritative transaction for owners, principals, proposers, and decision actors | Active/inactive membership fixtures; N100-03, N110-06/07; no cached membership authority | KBN-100, 110 | Better Auth session may remain valid for unrelated features | Controlled after evidence |
|
||||
| T03 | User joins/forges a team relation outside its workspace | Team-owned projects and tasks | Global-current-main team behavior or a stale membership is reused | Team is intra-workspace only; workspace/team composites; active workspace membership precedes team authorization | Cross-workspace team/member/owner insert and command denials; N100-04/05, N110-08 | KBN-100, 110 | Team-role policy mistakes remain possible | Controlled after evidence |
|
||||
| T04 | Same-workspace IDs from a different project are combined | Planning hierarchy integrity | Valid mission/milestone/parent/current-milestone UUIDs are substituted | Project-congruent composite relations and serialized hierarchy validation | Mission/milestone/parent/current milestone mismatch and parent-cycle tests; N100-06..10, N110-09 | KBN-100, 110 | Deep hierarchy checks can be expensive | Controlled after evidence |
|
||||
| T05 | Foreign or unrelated evidence/link/artifact IDs are attached | Review and audit truth | Caller has a valid same-workspace or foreign artifact UUID | Workspace-aware joins; immutable artifact digest/revision; semantic same-target validation in authoritative transaction | Mixed-workspace and same-workspace wrong-task/mission checkpoint/approval evidence tests; N100-11..14, N210-15/16 | KBN-100, 110, 210 | Same-workspace semantic validation is application-enforced | Controlled after evidence |
|
||||
| T06 | Stolen, over-scoped, or replayed service token | Coordinator and task mutation authority | Service credential is accepted as admin/user or claims are trusted without DB state | Command-family least privilege; agent/session workspace binding; no raw DB credentials; enabled/live state checked per command | Auth registry fixtures prove audience/expiry/role/capability; revoked agent and ended session denials; N105-01, N110-10..13, N210-01/02 | KBN-105, 110, 210 | Credential theft until expiry/revocation check | Controlled after evidence |
|
||||
| T07 | Caller forges public `healthy` or replays a stale health response | Sole-writer/fail-closed invariant | Public health body or caller field reaches mutation context | Public DTO is observation only; public DTOs reject proof/health fields; Gateway mints internal proof after live PG transaction probe | Contradictory union and forbidden-field tests; N105-02, N110-14..17 | KBN-105, 110, 140 | Health endpoint can still be used for reconnaissance | Controlled after evidence |
|
||||
| T08 | Internal stale, wrong-policy, or wrong-transaction proof is reused | Transaction integrity | A branded value leaks or an adapter fails to revalidate it | Non-exported brand; transaction identity, `checkedAt <= now < validUntil`, and policy revision revalidated immediately before mutation | Wrong transaction, expiry boundary, future timestamp, policy mismatch, commit-after-expiry tests; N110-18..22 | KBN-110, 140 | In-process code can bypass TypeScript; runtime checks are mandatory | Controlled after evidence |
|
||||
| T09 | DB/transport uncertainty is mislabeled as deliberate denial or conflict | Safe retry and exactly-once result | Timeout occurs before/after commit and client changes key or retries 503 | Exact 503/502/504/timeout/409 union; unknown outcome retries only with same idempotency key | Exhaustive fixture mapping and commit-before-timeout replay; N105-03, N110-23..27, N120-01/02 | KBN-105, 110, 120, 140 | External client may ignore retry rules | Controlled after evidence |
|
||||
| T10 | Assignment payload forges task version, target agent/session, role, expiry, or proposer | Work routing authority | Lease service trusts command DTO rather than persisted assignment | Persisted assignment identity; exactly-one principal/proposer; exact agent/session composite; acquire accepts IDs then reloads+locks | Cross-workspace and same-workspace target substitutions, stale task version, invalid role, expired assignment; N100-15..18, N210-03..08 | KBN-100, 200, 210 | Compromised authorized proposer can make harmful proposals | Controlled by approval/audit |
|
||||
| T11 | Approval proof is forged by value or borrowed from another assignment | Gate integrity | Caller submits `approved=true`, unrelated decision ID, stale policy, or self-approval | Relational approval bound to assignment; lock/reload; policy revision; author≠reviewer and mandatory SecReview | No proof-by-value DTO; wrong assignment/task/workspace/policy/actor/decision tests; N105-04, N210-09..14, N230-01 | KBN-105, 210, 230 | Colluding principals remain an organizational risk | Controlled after evidence |
|
||||
| T12 | Revoked policy or expired proposal/assignment is raced against lease acquisition | Routing policy | Approval and lease transactions do not lock/revalidate current rows | Lock assignment, approval, task, target session; compare current policy and expiry inside fresh-proof transaction | Concurrent revoke/expire/acquire tests with one valid terminal result; N210-17..19 | KBN-210, 230 | Clock skew if DB time is not canonical | Controlled after evidence |
|
||||
| T13 | Stale worker sends ack/heartbeat/checkpoint/review after reassignment | Canonical task and evidence state | Old process retains task/session IDs | Task-row-locked atomic monotonic bigint fence; every worker command carries exact lease/session/fence | Lower, expired, future, and other-task fences denied; old worker loses after new lease; N100-19/20, N210-20..24 | KBN-100, 210, 230 | Signed bigint exhaustion is theoretical | Controlled after evidence |
|
||||
| T14 | JavaScript precision truncates a fence | Stale-worker exclusion | bigint token is serialized as number above `2^53-1` | Drizzle bigint and decimal-string wire type only | `9007199254740993` and near-`int8` boundary round trips; numeric JSON rejected; N105-05, N210-25 | KBN-105, 210 | Nonconforming external clients | Controlled after evidence |
|
||||
| T15 | Checkpoint/evidence from another lease/task/session is submitted | Recovery and certification evidence | Same-workspace valid IDs are mixed | Exact lease composite binds workspace+task+assignment/session+fence; checkpoint composite binds lease+fence; evidence join plus semantic artifact-owner check | Same-workspace mismatched task/assignment/lease/session/checkpoint/artifact tests; N100-21..23, N210-26..31 | KBN-100, 210 | Artifact URI target may disappear outside DB | Controlled with digest/retention |
|
||||
| T16 | Outage note or pending/rejected proposal mutates/orders work | Sole SOT and gate integrity | Importer/UI treats note/proposal as task state | Proposals are inert; only explicit accept invokes normal typed command after recovery | Row/outbox/task counts unchanged for pending/rejected; no readiness/dependency/lease effect; N110-28..31 | KBN-110, 140 | Humans may act outside Mosaic operationally | Accepted as attributable residual |
|
||||
| T17 | Submission event is missing, foreign, or for another proposal | Proposal audit chain | Caller supplies an existing event UUID | Preallocated proposal ID; event-first same transaction; workspace composite FK; exact event type/aggregate/version semantic check | Missing/foreign/wrong-type/wrong-proposal event rolls back event+proposal; N100-24/25, N110-32..36 | KBN-100, 110 | Semantic checks are transaction code, not only FK | Controlled after evidence |
|
||||
| T18 | Acceptance borrows an unrelated command event | Proposal and target integrity | Same-workspace event exists for another target/command/proposal | Accept locks proposal+target, executes normal command, requires workspace/target match, causation=submission event, payload proposal ID | Foreign, wrong target/type/command/causation/payload event aborts target/event/proposal atomically; N100-26, N110-37..43 | KBN-100, 110 | Event payload schema drift | Controlled by KBN-105 fixtures |
|
||||
| T19 | Application role updates/deletes audit, approval evidence, checkpoint, or artifact | Nonrepudiation | Broad DB grants or parent cascade exists | INSERT/SELECT-only application roles; RESTRICT parent deletes; archive/cancel normal lifecycle | Role-level UPDATE/DELETE denied; parent delete RESTRICT; digest unchanged; N100-27..31 | KBN-100 | DB superuser can alter state | Break-glass/infra audit residual |
|
||||
| T20 | Break-glass purge is used as routine deletion or erases its own evidence | Retention and incident forensics | Elevated credential available | Separate audited retention procedure, bounded scope, reason, pre/post evidence, authority separation | Normal role denied; expired/missing approval denied; purge cannot delete its authorizing audit package; N115-01, N230-02/03 | KBN-115, 230 | Privileged DBA compromise | Accepted operational residual |
|
||||
| T21 | PostgreSQL unavailable or partitioned | Canonical state | Public health/Valkey remains live while transaction probe fails | Fail closed; no alternate writer/hidden queue; 503 only for proven not-applied; transport uncertainty remains unknown | Fault injection proves DB rows/outbox/files/Valkey unchanged on deliberate denial; commit-unknown replay; N110-44..48, N140-01 | KBN-110, 140, 230 | Availability loss is intentional | Accepted by Option A |
|
||||
| T22 | Valkey unavailable, duplicated, stale, or partitioned | Scheduling notifications | Queue wake is treated as truth or publication fails | Valkey derived/expendable; transactional outbox in PG; idempotent publisher; recovery from PG | Commit with Valkey down leaves pending outbox; replay publishes once logically; stale wake reloads PG; N110-49, N140-02, N230-04..06 | KBN-110, 210, 230 | Duplicate at-least-once delivery | Consumers must be idempotent |
|
||||
| T23 | Coordinator restarts between assignment, lease, checkpoint, or outbox steps | Durable orchestration truth | Process-local cache is treated as authority | PostgreSQL stores assignments, execution state, leases, fences, checkpoints, events, outbox; `recoverFromPostgres` | Restart at every transaction boundary reconstructs identical active/expired/pending sets without Valkey/files; N210-32..36, N230-07 | KBN-210, 230 | Recovery latency | Controlled after evidence |
|
||||
| T24 | Dependency cycle or concurrent reciprocal edge | Readiness and dispatch safety | Two transactions each see an acyclic graph before inserting | Unique directed edge; no self-edge; serialized recursive cycle check; readiness evaluates all blockers | Self/duplicate/cycle and concurrent A→B/B→A tests; all predecessor property test; N100-32..35, N200-01/02 | KBN-100, 200, 230 | Very large DAG performance | Bounded operational residual |
|
||||
| T25 | Parent-task cycle or project-incongruent relation | Planning hierarchy | Valid same-workspace IDs are arranged into an invalid tree | Project-congruent composites; serialized parent-cycle/orphan validation required by REQ-PLAN-001 | Self/indirect parent cycle, orphan, and cross-project mission/milestone/parent tests; N100-06..10 | KBN-100, 110 | Cycle validation is service/transaction enforced | Controlled after evidence |
|
||||
| T26 | Concurrent update, duplicate retry, or idempotency payload drift | Aggregate consistency | Two clients use same version/key with different payloads | Expected-version check; semantic event and outbox in same transaction; key returns prior immutable result only for identical command | One update wins; stale gets 409; duplicate identical returns prior; payload drift rejected; N110-50..54, N140-03 | KBN-105, 110, 140 | Long-lived clients face visible conflicts | Intentional user-visible residual |
|
||||
| T27 | State/event/outbox partial commit | Audit and notification consistency | Separate transactions or exception after state write | One PostgreSQL transaction for state+semantic event+outbox | Failure injected after each insert rolls all three back; success revisions align; N110-55..58 | KBN-110, 140 | Outbox publication remains asynchronous | Controlled after evidence |
|
||||
| T28 | Malicious/incorrect importer injects foreign workspace data or dispatchable work | Migration integrity | Source keys collide, lineage is absent, or importer has direct DB authority | Immutable source snapshots/checksums; one-way Gateway/migration-only port; workspace-safe idempotent modes; shadow records cannot dispatch | Foreign/malformed/duplicate/partial-resume/lineage checksum and no-dispatch tests; N300-01..08 | KBN-300, 330 | Source data may be semantically ambiguous | Quarantine and owner sign-off |
|
||||
| T29 | Cutover leaves legacy writer or forward/reverse sync active | Sole-writer invariant | Credentials/processes survive switch or rollback is improvised | Writer inventory, freeze, final delta, Gateway switch, credential shutdown, no dual write; rollback authority changes after first DB mutation | Process/credential inventory; concurrent-writer assertion; before/after-mutation rollback rehearsal; N320-01..06, N330-01 | KBN-320, 330, 340 | Missed external automation | Owner-gated residual |
|
||||
| T30 | Generated `TASKS.md`/`mission.json` is edited or parsed into DB | Canonical state | Current-main parser/writer remains reachable or file watcher imports changes | Generated non-authoritative header/IDs/time/revision; no production importer; regenerate/overwrite only | Static import search, tamper/regeneration, read-only permission, source-revision parity; N120-03..07, N140-04 | KBN-120, 140 | Humans may mistake snapshots for live data | Header and docs mitigate |
|
||||
| T31 | N-1 compatibility copies legacy ambiguity into canonical authority | Data integrity | Nullable/global/current-main fields are guessed during backfill | Nullable-first expand; deterministic mapping or quarantine; checksums; no new-only status before switch; legacy fields retained | Production-shape, ambiguous owner/assignee, status shadow, JSON/config/digest, rollback tests; N100-36..44 | KBN-100 | Quarantined records require human decision | Controlled by signed reconciliation |
|
||||
| T32 | Recovery posture claims durability not provided by mechanisms | Availability and audit retention | Shape-only validation or optimistic RPO is accepted | Normative validator; WAL/PITR/RPO/storage/high-assurance constraints; mechanism and restore evidence | Unknown/impossible/weakened configuration plus actual mechanism/restore tests; N115-02..08 | KBN-115 | Backup operator or storage compromise | Separate failure domain residual |
|
||||
| T33 | Frozen DDL cannot create mission-scoped evidence/approval FKs | Tenant/evidence relational integrity | KBN-100 generates DDL from current contract | Intended workspace-aware mission FKs exist, but referenced `(workspace_id,id)` candidate key is absent | PostgreSQL migration test must fail before amendment; after amendment, empty/prod migration and cross-workspace FK tests must pass | Contract authority, then KBN-100 | None acceptable before schema release | **BLOCKER KBN010-SI-001** |
|
||||
|
||||
## 5. Constraint-impact matrix
|
||||
|
||||
| Impact ID | Required invariant | Frozen schema impact | API/transaction impact | Required evidence | Owner | Status |
|
||||
| --------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------- |
|
||||
| CI-01 | Hard workspace tenancy and no oracle | `workspace_id`, workspace-aware unique/FKs on all canonical rows | Server-derived workspace; uniform denial on all surfaces | N100-01..14; N110-01..09; N130-01 | KBN-100/105/110/130 | Resolved by frozen controls |
|
||||
| CI-02 | Active user membership | Membership row plus unique `(workspace_id,user_id)`; active state retained | Recheck active membership in same authoritative transaction | N100-03; N110-06/07 | KBN-100/110 | Resolved; not FK-only |
|
||||
| CI-03 | Service identity least privilege/revocation | Agent/session workspace, lifecycle, state, roles, capabilities | Token maps to exact agent/session; command-family allowlist; DB recheck; no admin/raw DB fallback | N105-01; N110-10..13; N210-01/02 | KBN-105/110/210 | Resolved at auth/API layer |
|
||||
| CI-04 | Project-congruent hierarchy | Composite project/mission/milestone/parent/current-milestone relations | Lock/serialized parent-cycle and orphan validation | N100-06..10 | KBN-100/110 | Resolved; cycle behavior required |
|
||||
| CI-05 | Health-proof authority | Internal branded proof has transaction/time/policy fields | Probe and revalidate on same PG transaction; no public field | N105-02/03; N110-14..27 | KBN-105/110 | Resolved by frozen controls |
|
||||
| CI-06 | Assignment/approval identity | Exactly-one principal/proposer, exact agent/session assignment, relational approval | Reload+lock all IDs; compare version/target/state/expiry/policy/decision | N100-15..18; N210-03..19 | KBN-100/210 | Resolved by frozen controls |
|
||||
| CI-07 | Monotonic bigint fencing | Durable bigint counter, exact lease/fence keys, one active lease | Atomic increment/RETURNING; decimal-string DTO; reject every stale worker command | N100-19..23; N210-20..31 | KBN-100/105/210 | Resolved by frozen controls |
|
||||
| CI-08 | Proposal event chain | Both workspace-aware event FKs; event table created first | Exact submission/acceptance semantic checks in one transaction | N100-24..26; N110-28..43 | KBN-100/110 | Resolved; semantic checks not FK-only |
|
||||
| CI-09 | Immutable audit/evidence retention | RESTRICT parents; INSERT/SELECT-only immutable tables | Archive/cancel normal flow; separately authorized purge | N100-27..31; N115-01; N230-02/03 | KBN-100/115/230 | Resolved by frozen controls |
|
||||
| CI-10 | DB/Valkey/outbox/restart semantics | PG outbox and durable orchestration rows | Fail closed; same-key uncertainty retry; Valkey reloads PG; restart from PG | N110-44..49; N140-01/02; N230-04..07 | KBN-110/210/230 | Resolved by frozen controls |
|
||||
| CI-11 | DAG/race/idempotency/version | Unique edge; self check; event idempotency; aggregate versions | Serialized recursive cycle check; payload binding; expected-version conflict | N100-32..35; N110-50..58; N200-01/02 | KBN-100/110/200 | Resolved by frozen controls |
|
||||
| CI-12 | Import/cutover trust boundary | Lineage/artifact/event fields; shadow state cannot dispatch | One-way scoped importer, freeze, no direct DB/file authority, no dual writer | N300-01..08; N320-01..06 | KBN-300/320/330 | Resolved by frozen controls |
|
||||
| CI-13 | Generated-file no-import | No canonical file schema/import contract | Projection-only package; static reachability check removes current parser from production Kanban paths | N120-03..07; N140-04 | KBN-120/140 | Resolved by frozen controls |
|
||||
| CI-14 | Mission-scoped artifact and approval FKs | **Missing unique candidate key `(workspace_id,id)` on `missionsV1` for two frozen composite FKs** | No API control can make invalid migration DDL creatable | Amendment plus empty/prod migration proof and foreign-workspace tests | Contract authority; KBN-100 after amendment | **UNRESOLVED / BLOCKER** |
|
||||
|
||||
## 6. Exact future negative-test catalog
|
||||
|
||||
These names are normative evidence identifiers for future slices. Equivalent test-file names are acceptable only if traceability retains these IDs and expected outcomes.
|
||||
|
||||
### KBN-100 — schema and migration
|
||||
|
||||
- **N100-01** reject every canonical child row whose `workspace_id` differs from its parent.
|
||||
- **N100-02** reject foreign-workspace link, artifact, proposal target, dependency, assignment, lease, checkpoint, approval, and event relationships.
|
||||
- **N100-03** reject an inactive/revoked member as accountable owner, proposer, decision actor, archive actor, or user principal in the authoritative command transaction.
|
||||
- **N100-04** reject a team/project relation crossing workspaces.
|
||||
- **N100-05** reject a team authorization path when the user lacks active membership in the team's workspace.
|
||||
- **N100-06** reject task→mission project mismatch.
|
||||
- **N100-07** reject task→milestone and project→current-milestone project mismatch.
|
||||
- **N100-08** reject task→parent project mismatch and self-parent.
|
||||
- **N100-09** reject indirect parent cycles under concurrent transactions.
|
||||
- **N100-10** reject mission→milestone project mismatch/orphan.
|
||||
- **N100-11** reject checkpoint artifact from another workspace.
|
||||
- **N100-12** reject checkpoint artifact owned by another same-workspace task/mission unless an explicitly frozen evidence rule permits it.
|
||||
- **N100-13** reject approval evidence from another workspace.
|
||||
- **N100-14** reject same-workspace approval evidence unrelated to the approval target.
|
||||
- **N100-15** reject zero/multiple assignment principals and zero/multiple proposers.
|
||||
- **N100-16** reject target session without its exact target agent.
|
||||
- **N100-17** reject assignment task/agent/session crossing workspaces.
|
||||
- **N100-18** reject non-positive task version and expired assignment acquisition.
|
||||
- **N100-19** concurrent lease insert permits one active lease and returns one winner.
|
||||
- **N100-20** successive leases return strictly increasing bigint fences.
|
||||
- **N100-21** reject checkpoint with another task, lease, or fence.
|
||||
- **N100-22** reject duplicate/non-monotonic checkpoint sequence.
|
||||
- **N100-23** reject evidence join for a mismatched checkpoint/task.
|
||||
- **N100-24** proposal insert without exact submission event fails atomically.
|
||||
- **N100-25** foreign/wrong-type/wrong-proposal submission event fails atomically.
|
||||
- **N100-26** foreign/wrong-target/unrelated acceptance event fails atomically.
|
||||
- **N100-27** application role cannot UPDATE/DELETE `task_events`.
|
||||
- **N100-28** application role cannot UPDATE/DELETE checkpoints/artifacts/evidence joins.
|
||||
- **N100-29** parent hard delete is RESTRICTed while audit/evidence children exist.
|
||||
- **N100-30** archive does not alter canonical lifecycle status.
|
||||
- **N100-31** purge without break-glass authority/evidence is denied.
|
||||
- **N100-32** reject dependency self-edge and duplicate directed pair regardless of type.
|
||||
- **N100-33** reject direct and indirect dependency cycles.
|
||||
- **N100-34** concurrent reciprocal dependency inserts cannot both commit.
|
||||
- **N100-35** readiness remains false until every blocking predecessor and completion condition passes.
|
||||
- **N100-36** empty DB migration succeeds after the contract amendment.
|
||||
- **N100-37** production-shape expand retains all legacy declarations.
|
||||
- **N100-38** crash/resume backfill is idempotent and checksum-stable.
|
||||
- **N100-39** ambiguous workspace/owner/assignee is quarantined, never guessed.
|
||||
- **N100-40** no `ready`/`in_review` status is emitted to N-1 readers before switch.
|
||||
- **N100-41** `mission_tasks.status` cannot remain a write source.
|
||||
- **N100-42** tags/assignee/date/mission JSON/config/description/agent fields reconcile without loss.
|
||||
- **N100-43** claimed fleet backlog rows are quarantined and imported rows cannot dispatch.
|
||||
- **N100-44** pre-switch rollback works while post-first-mutation rollback requires freeze/reconciliation.
|
||||
- **N100-45** before amendment, PostgreSQL rejects both mission composite FKs for lack of a matching `(workspace_id,id)` candidate key; after amendment, both DDL relations and foreign-workspace denials pass.
|
||||
|
||||
### KBN-105/KBN-110/KBN-120/KBN-130/KBN-140 — API and P1
|
||||
|
||||
- **N105-01** every route has an explicit user/service command-family policy; user/admin tokens cannot call service-only Coordinator mutations.
|
||||
- **N105-02** public DTO validation rejects `writeProof`, internal context, body `workspaceId`, and caller-asserted health.
|
||||
- **N105-03** fixture exhaustiveness prevents 503, 502/504/timeout, and 409 cross-mapping.
|
||||
- **N105-04** approval DTO accepts an ID and decision command only, never approval proof-by-value.
|
||||
- **N105-05** all fence fields accept/emit decimal strings and reject JSON numbers.
|
||||
- **N110-01** listing with a foreign `workspaceId` or foreign filter ID follows the frozen no-oracle denial and returns no rows/counts/cursors.
|
||||
- **N110-02** get by foreign or nonexistent aggregate ID has the same frozen denial shape and no foreign metadata.
|
||||
- **N110-03** create/update/archive with a foreign owner, parent, project, mission, milestone, tag, or target ID is denied before mutation.
|
||||
- **N110-04** dependency/proposal commands with foreign target IDs are denied with unchanged state/event/outbox counts.
|
||||
- **N110-05** REST, MCP, WebSocket, and internal Coordinator paths produce equivalent no-oracle behavior for the same foreign ID.
|
||||
- **N110-06** a revoked/inactive owner is denied even with a still-valid Better Auth session.
|
||||
- **N110-07** stale membership/team cache cannot authorize a proposer, decision actor, archive actor, or principal after revocation.
|
||||
- **N110-08** a team ID from another workspace cannot authorize or own the command target.
|
||||
- **N110-09** same-workspace but wrong-project mission/milestone/parent IDs are denied inside the transaction.
|
||||
- **N110-10** an expired service token is denied before repository access.
|
||||
- **N110-11** an audience- or workspace-mismatched service token is denied without an existence oracle.
|
||||
- **N110-12** an over-scoped service token cannot call a command family absent from its role/capability allowlist.
|
||||
- **N110-13** disabled agent or ended session revokes service-token command authority immediately on PostgreSQL recheck.
|
||||
- **N110-14** contradictory public health state/boolean combinations fail validation.
|
||||
- **N110-15** Valkey-only liveness cannot mint or substitute a PostgreSQL write proof.
|
||||
- **N110-16** caller-forged public `healthy` cannot enter internal mutation context.
|
||||
- **N110-17** public REST/MCP/CLI bodies containing health/proof fields are rejected.
|
||||
- **N110-18** an expired internal proof produces no state/event/outbox write.
|
||||
- **N110-19** a future-dated or not-yet-valid proof produces no write.
|
||||
- **N110-20** a policy-revision-mismatched proof produces no write.
|
||||
- **N110-21** a proof minted on another transaction/connection produces no write.
|
||||
- **N110-22** a proof that expires before the final pre-mutation check produces no write.
|
||||
- **N110-23** deliberate read-only/write-unavailable denial maps only to authoritative 503/not-applied/non-retryable.
|
||||
- **N110-24** timeout before commit maps to transport-unknown and permits only same-key retry.
|
||||
- **N110-25** timeout after commit maps to transport-unknown and same-key retry returns the committed canonical result once.
|
||||
- **N110-26** expected-version mismatch maps only to 409/not-applied/non-retryable.
|
||||
- **N110-27** recovery replay with a changed idempotency key cannot masquerade as the original uncertain request.
|
||||
- **N110-28** pending proposal cannot alter target fields/status/rank/version.
|
||||
- **N110-29** rejected proposal cannot affect readiness, dependencies, or gates.
|
||||
- **N110-30** pending/rejected proposal cannot create an assignment or lease.
|
||||
- **N110-31** direct proposal-row state manipulation cannot bypass normal command execution.
|
||||
- **N110-32** proposal submission without a submission event rolls back fully.
|
||||
- **N110-33** foreign-workspace submission event rolls back fully.
|
||||
- **N110-34** wrong aggregate/event type submission event rolls back fully.
|
||||
- **N110-35** same-workspace event for another proposal rolls back fully.
|
||||
- **N110-36** submission event with wrong previous/new version semantics rolls back fully.
|
||||
- **N110-37** foreign-workspace acceptance event rolls back proposal, target, event, and outbox.
|
||||
- **N110-38** same-workspace event for another target aggregate rolls back acceptance.
|
||||
- **N110-39** event from an unrelated normal command rolls back acceptance.
|
||||
- **N110-40** event caused by a different submission event rolls back acceptance.
|
||||
- **N110-41** event whose payload lacks or changes `changeProposalId` rolls back acceptance.
|
||||
- **N110-42** event for another proposal with the same target/command rolls back acceptance.
|
||||
- **N110-43** missing accepted-command event after target handling rolls back the entire transaction.
|
||||
- **N110-44** read-only-degraded denial changes no DB row/outbox/file/Valkey/provider state.
|
||||
- **N110-45** write-unavailable denial changes no DB row/outbox/file/Valkey/provider state.
|
||||
- **N110-46** PostgreSQL disconnect cannot redirect a command to any fallback writer.
|
||||
- **N110-47** commit uncertainty remains `unknown` and never becomes a fabricated 503/not-applied result.
|
||||
- **N110-48** same-key replay after recovery returns one canonical result with no duplicate event/outbox row.
|
||||
- **N110-49** Valkey publication failure leaves committed PG outbox pending and replayable.
|
||||
- **N110-50** two same-version updates produce one winner and one visible 409 loser.
|
||||
- **N110-51** identical duplicate key+payload returns the prior immutable result without another event/outbox row.
|
||||
- **N110-52** same key with payload/command drift is rejected as an idempotency conflict.
|
||||
- **N110-53** the same key in another workspace cannot reveal or reuse the first workspace's result.
|
||||
- **N110-54** stale reconnect/update cannot silently overwrite a newer aggregate revision.
|
||||
- **N110-55** failure after state write but before semantic event rolls back state.
|
||||
- **N110-56** failure after semantic event but before outbox rolls back state and event.
|
||||
- **N110-57** failure after outbox insert but before commit rolls back state, event, and outbox.
|
||||
- **N110-58** success commits matching aggregate/event/outbox revisions and correlation/causation.
|
||||
- **N120-01** CLI never retries an authoritative 503 deliberate denial.
|
||||
- **N120-02** CLI retries only transport-unknown outcomes and preserves the exact idempotency key.
|
||||
- **N120-03** generated projection header contains non-authoritative warning, workspace/project IDs, generated time, and source revision.
|
||||
- **N120-04** projection revision and records match the API snapshot revision exactly.
|
||||
- **N120-05** hand-tampering is overwritten or rejected by regeneration and never mutates PostgreSQL.
|
||||
- **N120-06** static/runtime reachability finds no parser/import path from `TASKS.md`, `mission.json`, or another export.
|
||||
- **N120-07** projection writer has no domain mutation/raw SQL/Valkey authority.
|
||||
- **N130-01** UI foreign/no-access/not-found state follows the frozen no-oracle response and renders no stale foreign data.
|
||||
- **N140-01** real-Gateway DB fault journey proves fail-closed no-fallback behavior.
|
||||
- **N140-02** real-Gateway Valkey-loss journey proves pending outbox replay.
|
||||
- **N140-03** real-Gateway concurrent update/retry journey proves version and idempotency semantics.
|
||||
- **N140-04** generated-file tamper journey proves projection parity and no import.
|
||||
|
||||
### KBN-115/KBN-200/KBN-210/KBN-230 — recovery and coordination
|
||||
|
||||
- **N115-01** retention purge without current break-glass authority, reason, immutable evidence, or bounded scope is denied and audited.
|
||||
- **N115-02** recovery posture with an unknown top-level or storage field is rejected.
|
||||
- **N115-03** PITR retention without WAL archival is rejected.
|
||||
- **N115-04** WAL archival with zero PITR retention is rejected.
|
||||
- **N115-05** claimed RPO better than the configured backup/WAL mechanism is rejected.
|
||||
- **N115-06** unencrypted, optional, or same-failure-domain storage is rejected.
|
||||
- **N115-07** weakened high-assurance values are rejected.
|
||||
- **N115-08** shape-only validation cannot pass without normative mechanism and restore evidence.
|
||||
- **N200-01** cyclic/incomplete dependency snapshots never become eligible.
|
||||
- **N200-02** identical immutable snapshot+policy+time returns identical ordering and explanation with no I/O/model import.
|
||||
- **N210-01** disabled agent cannot claim, ack, heartbeat, checkpoint, or submit review.
|
||||
- **N210-02** ended/offline/mismatched session cannot claim, ack, heartbeat, checkpoint, or submit review.
|
||||
- **N210-03** foreign-workspace task is rejected after lock/reload without an oracle.
|
||||
- **N210-04** stale task version is rejected before fence increment.
|
||||
- **N210-05** assignment target agent mismatch is rejected.
|
||||
- **N210-06** target session mismatch is rejected.
|
||||
- **N210-07** expired assignment is rejected.
|
||||
- **N210-08** assignment in rejected/released/expired/superseded/leased-invalid state is rejected.
|
||||
- **N210-09** missing approval is rejected.
|
||||
- **N210-10** rejected/escalated/requested approval is rejected as approval authority.
|
||||
- **N210-11** stale policy-revision approval is rejected.
|
||||
- **N210-12** foreign-workspace approval is rejected without an oracle.
|
||||
- **N210-13** approval for another assignment is rejected.
|
||||
- **N210-14** author self-approval/review is rejected when independence is required.
|
||||
- **N210-15** foreign-workspace artifact evidence is rejected.
|
||||
- **N210-16** same-workspace artifact unrelated to the assignment/task/gate is rejected.
|
||||
- **N210-17** concurrent policy revocation versus acquire cannot produce a lease under the revoked revision.
|
||||
- **N210-18** concurrent assignment expiry versus acquire cannot produce a lease after expiry.
|
||||
- **N210-19** concurrent session end versus acquire cannot produce a lease for the ended session.
|
||||
- **N210-20** lower fencing token is rejected without writes.
|
||||
- **N210-21** token from an older lease is rejected without writes.
|
||||
- **N210-22** token paired with another task is rejected without writes.
|
||||
- **N210-23** token paired with another session is rejected without writes.
|
||||
- **N210-24** token on an expired/revoked/released lease is rejected without writes.
|
||||
- **N210-25** fences above JavaScript safe integer round-trip exactly as decimal strings.
|
||||
- **N210-26** lease task does not match assignment task and is rejected.
|
||||
- **N210-27** lease agent/session does not match assignment target and is rejected.
|
||||
- **N210-28** checkpoint task does not match lease task and is rejected.
|
||||
- **N210-29** checkpoint fence does not match exact lease fence and is rejected.
|
||||
- **N210-30** checkpoint sequence duplicate/regression is rejected.
|
||||
- **N210-31** checkpoint artifact does not match workspace/task/evidence semantics and is rejected.
|
||||
- **N210-32** restart after assignment persistence reconstructs the pending assignment.
|
||||
- **N210-33** restart after lease commit reconstructs exact active lease and fence.
|
||||
- **N210-34** restart after checkpoint commit reconstructs checkpoint/recovery state.
|
||||
- **N210-35** restart during expiry/retry/quarantine reconstructs durable disposition and eligibility.
|
||||
- **N210-36** restart with pending outbox reconstructs publication work without Valkey/files.
|
||||
- **N230-01** author=self-review and missing mandatory SecReview cannot certify or complete.
|
||||
- **N230-02** normal application role cannot execute retention purge.
|
||||
- **N230-03** break-glass purge cannot delete or alter its own authorization/evidence chain.
|
||||
- **N230-04** Valkey down leaves canonical work in PostgreSQL/outbox.
|
||||
- **N230-05** duplicate wake produces one logical effect after PostgreSQL reload/idempotency.
|
||||
- **N230-06** stale wake cannot revive an expired/revoked assignment or lease.
|
||||
- **N230-07** restart with no Valkey/files reconstructs leases/retry/quarantine/outbox exactly.
|
||||
|
||||
### KBN-300/KBN-320/KBN-330/KBN-340 — migration and cutover
|
||||
|
||||
- **N300-01** source record targeting another workspace is denied/quarantined without an oracle.
|
||||
- **N300-02** malformed source record is rejected with attributable reject evidence.
|
||||
- **N300-03** duplicate source system/key/batch replay is idempotent.
|
||||
- **N300-04** source snapshot/checksum drift aborts apply/verify.
|
||||
- **N300-05** partial import resumes from durable lineage without duplicating state/events.
|
||||
- **N300-06** imported shadow record cannot become ready, assigned, or leased automatically.
|
||||
- **N300-07** missing source key/file/checksum/batch lineage prevents apply/sign-off.
|
||||
- **N300-08** importer cannot use direct DB, generated file, Valkey, or provider issue as canonical write authority.
|
||||
- **N320-01** cutover without a verified write freeze fails safe.
|
||||
- **N320-02** active legacy writer process or credential blocks cutover.
|
||||
- **N320-03** reverse and forward synchronization cannot run concurrently.
|
||||
- **N320-04** failed final delta/reconciliation blocks client switch.
|
||||
- **N320-05** rollback before first canonical DB mutation may switch authority back only after freeze assertion.
|
||||
- **N320-06** rollback after first canonical mutation requires freeze, DB-delta export/reconciliation, and owner decision.
|
||||
- **N330-01** rehearsal cannot sign off while counts/checksums/exceptions/writer inventory differ.
|
||||
- **N340-01** cutover cannot proceed without owner authorization, terminal evidence, scoped identities, and zero active legacy writers.
|
||||
|
||||
## 7. Requirements traceability
|
||||
|
||||
| Requirement | Threats/impacts | Planned evidence |
|
||||
| ---------------- | ---------------------------- | --------------------------------------------------------------- |
|
||||
| REQ-SOT-001 | T16, T21, T22, T27, T29, T30 | N110-28..31, N110-44..49, N110-55..58, N120-03..07, N320-01..06 |
|
||||
| REQ-SOT-002 | T07, T08, T09, T21 | N105-02/03, N110-14..27, N110-44..48 |
|
||||
| REQ-SOT-003 | T30 | N120-03..07, N140-04 |
|
||||
| REQ-SOT-004 | T16..18 | N100-24..26, N110-28..43 |
|
||||
| REQ-TEN-001 | T01..05, T15, T33 | N100-01..14, N110-01..09, N210-15/16, N100-45 |
|
||||
| REQ-ID-001 | T02, T03, T06, T10..12 | N105-01, N110-06..13, N210-01..19 |
|
||||
| REQ-PLAN-001 | T04, T25 | N100-06..10 |
|
||||
| REQ-TASK-001 | T13, T26, T31 | N100-20, N100-37..42, N110-50..54 |
|
||||
| REQ-TASK-002 | T16, T24 | N110-28..31, N100-35, N200-01 |
|
||||
| REQ-DEP-001 | T24 | N100-32..35, N200-01 |
|
||||
| REQ-ASN-001 | T10..12 | N100-15..18, N210-03..19 |
|
||||
| REQ-AUD-001 | T17..20, T22, T27 | N100-24..31, N110-32..43, N110-49, N110-55..58 |
|
||||
| REQ-API-001 | T01, T06..18, T26 | N105-01..05 plus KBN-110 catalog |
|
||||
| REQ-UI-002/003 | T01, T15, T26 | N130-01 and real-Gateway KBN-140 journeys |
|
||||
| REQ-COORD-001 | T22..24 | N200-01/02, N210-32..36 |
|
||||
| REQ-COORD-002 | T10..12, T16 | N210-03..19, N110-28..31 |
|
||||
| REQ-COORD-003 | T13..15, T23 | N100-19..23, N210-20..36 |
|
||||
| REQ-COORD-004 | T23, T26 | N210-32..36, N230-07 |
|
||||
| REQ-GATE-001/002 | T11, T19, T20 | N210-09..14, N230-01..03 |
|
||||
| REQ-REC-001 | T20, T32 | N115-01..08 |
|
||||
| REQ-MIG-001/002 | T28, T29, T31 | N100-37..44, N300-01..08, N320-01..06, N330-01, N340-01 |
|
||||
|
||||
REQ-UI-001 and REQ-UI-004 are downstream functional/accessibility requirements rather than schema-threat controls; they remain owned by KBN-130/KBN-140. Their security-relevant tenancy, conflict, and stale-reconnect portions are covered above.
|
||||
|
||||
## 8. Issue #753 acceptance mapping
|
||||
|
||||
| Issue requirement/criterion | Evidence in this document | Result |
|
||||
| --------------------------------------------------------------- | ----------------------------------------------- | ------------------------- |
|
||||
| Cross-workspace owners, principals, evidence, project hierarchy | T01–T05, T15, T25; CI-01–04 | Mapped |
|
||||
| Active membership and service-token boundaries | Authorization matrix; T02, T03, T06; CI-02/03 | Mapped |
|
||||
| Stale/forged health and transaction-local proof | T07–T09, T21; CI-05 | Mapped |
|
||||
| Assignment/approval forgery and monotonic fencing | T10–T15; CI-06/07 | Mapped |
|
||||
| Change-proposal abuse and event binding | T16–T18; CI-08 | Mapped |
|
||||
| Immutable audit and break-glass | T19/T20; CI-09 | Mapped |
|
||||
| PostgreSQL/Valkey failures | T21–T23; CI-10 | Mapped |
|
||||
| Dependency/idempotency/version races | T24–T27; CI-11 | Mapped |
|
||||
| Import/cutover and generated-file boundary | T28–T31; CI-12/13 | Mapped |
|
||||
| Every schema/API/test impact explicit | Constraint matrix and negative-test catalog | Mapped |
|
||||
| No unresolved schema impact | CI-14 / KBN010-SI-001 | **Failed — blocker** |
|
||||
| Independent SecReview | Must occur after contract-authority disposition | Pending; gate cannot pass |
|
||||
|
||||
## 9. UNRESOLVED SCHEMA IMPACTS
|
||||
|
||||
**KBN010-SI-001 — `missions(workspace_id,id)` is not a declared candidate key for two frozen composite foreign keys.**
|
||||
|
||||
Affected frozen declarations in `contracts/kanban-schema.v1.ts`:
|
||||
|
||||
- `missions_workspace_project_id_uidx` is `(workspace_id, project_id, id)`.
|
||||
- `artifacts_workspace_mission_fk` references `(workspace_id, mission_id)` → `missions(workspace_id, id)`.
|
||||
- `approval_decisions_workspace_mission_fk` references `(workspace_id, mission_id)` → `missions(workspace_id, id)`.
|
||||
|
||||
**Required disposition:** contract authority must amend/version the frozen schema, update this gate's frozen-input digest/reference, and obtain independent schema/SecReview. KBN-100 remains held. No worker may silently add an index or alter child keys during implementation.
|
||||
|
||||
## 10. Residual risk and handoff
|
||||
|
||||
- Even after schema amendment, active membership, polymorphic targets, same-task evidence semantics, parent/DAG cycle checks, token scope, and no-oracle behavior depend on authoritative transaction code and must not be treated as FK-only guarantees.
|
||||
- DB superuser and break-glass compromise cannot be eliminated by application constraints; separation of duties, immutable external backup/audit evidence, drills, and monitoring remain required.
|
||||
- PostgreSQL unavailability intentionally sacrifices writes for integrity. Transport-unknown outcomes remain safe only when clients preserve the exact idempotency key.
|
||||
- Imported ambiguous records remain quarantined until owner sign-off; no automated mapping may convert ambiguity into authority.
|
||||
|
||||
**Handoff status:** BLOCKED. Contract authority action is required before independent SecReview can return PASS and before KBN-100 is released.
|
||||
195
docs/native-kanban-sot/MISSION-MANIFEST.md
Normal file
195
docs/native-kanban-sot/MISSION-MANIFEST.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3
|
||||
|
||||
**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
**Date:** 2026-07-14
|
||||
**Human decision owner:** Jason
|
||||
**Orchestrator/publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage)
|
||||
**Execution topology:** USC web1, partitioned across collision-free GPT coder2/3/4/5 lanes
|
||||
**Canonical requirements:** [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md)
|
||||
**Frozen integration contract:** `SHARED-CONTRACT.md` and `contracts/*.v1.ts`
|
||||
|
||||
## 1. Mission statement
|
||||
|
||||
Extend current `mosaicstack/stack` main into the sole native control plane for workspace-scoped project, mission, milestone, task, dependency, assignment, lease, approval, evidence, and audit state. First deliver a thin writable Kanban/List vertical slice; then add deterministic mechanical coordination and execute a one-way migration/cutover from jarvis-brain/Vikunja project/task stores.
|
||||
|
||||
Success means every user, agent, orchestrator, specialist, and UI sees and mutates the same PostgreSQL aggregate revisions through typed Gateway commands, with no writable fallback and no hidden second authority.
|
||||
|
||||
## 2. Scope boundaries
|
||||
|
||||
### In scope
|
||||
|
||||
- Current Drizzle/PostgreSQL schema extension and migrations.
|
||||
- Workspace tenancy and authorization from the first migration.
|
||||
- Projects, missions, milestones, tasks, normalized tags, dependencies, assignments, durable execution/quarantine state, links, immutable artifacts/evidence joins, outage change proposals, events, approvals, leases, checkpoints, and transactional outbox.
|
||||
- NestJS Gateway queries and explicit lifecycle commands.
|
||||
- MCP/CLI agent surfaces and generated read-only projections.
|
||||
- Thin writable Next.js Tasks Kanban/List, task detail, minimal Projects CRUD, filters, dependency readiness, ownership/lease separation, and audit timeline.
|
||||
- Non-LLM Mechanical Coordinator eligibility, proposal, approval-policy, lease/fence, heartbeat, retry, expiry, quarantine, and restart recovery.
|
||||
- Planning, Enhance, Coder, Review, SecReview, PR-Monitor, and Certifier role/gate representation.
|
||||
- One-way shadow importer, reconciliation, write freeze, final delta, cutover, rollback package, and legacy read-only stabilization.
|
||||
- Recovery-posture configuration and health-state/fail-closed contract.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Greenfield services, Prisma runtime revival, or jarvis-brain flat files as runtime storage.
|
||||
- Writable Markdown/JSON/Valkey/browser/provider fallback.
|
||||
- Gitea issue/PR replacement or generic bidirectional provider sync.
|
||||
- Calendar, email, GLPI cache, CRM, billing, time tracking, personal-brain migration.
|
||||
- LLM scheduling or scope interpretation by the Coordinator.
|
||||
- Autonomous gate waiver, certification, merge, release, deployment, or issue closure by Coordinator.
|
||||
- Merge authority for Certifier.
|
||||
- P4 full portfolio/mission designer and P5 fleet-scale policy unless separately released.
|
||||
|
||||
## 3. Fixed invariants
|
||||
|
||||
Every deployment MUST preserve all of the following:
|
||||
|
||||
1. PostgreSQL is the sole writable SOT.
|
||||
2. Drizzle on current stack main is the only persistence foundation.
|
||||
3. Mutations fail closed when DB write-health cannot be proven `healthy`.
|
||||
4. No file, Valkey, browser, queue, provider, or human note becomes a fallback writer.
|
||||
5. `TASKS.md`, `mission.json`, and every file export are generated, read-only, non-authoritative, and never import sources.
|
||||
6. Human outage notes become attributable post-recovery proposals only.
|
||||
7. Workspace is the hard tenant; Team is intra-workspace authorization.
|
||||
8. Valkey is expendable; PostgreSQL owns state, leases, fencing, audit, and outbox.
|
||||
9. Mechanical Coordinator is deterministic/non-LLM and cannot invent scope, waive gates, certify, or merge.
|
||||
10. Certifier is the final independent quality gate and has no merge authority.
|
||||
11. Mutations use idempotency and optimistic aggregate versions; worker commands also require a current fencing token.
|
||||
12. Recovery tier changes only backup/recovery posture, never authority or gate semantics.
|
||||
|
||||
## 4. Configurable recovery posture
|
||||
|
||||
Deployments select Lite, Standard, or High-assurance defaults from [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md) and `contracts/recovery-posture.v1.ts`. Configurable fields are limited to:
|
||||
|
||||
- backup/base-backup cadence;
|
||||
- RPO and RTO targets;
|
||||
- PITR retention;
|
||||
- WAL archive cadence;
|
||||
- restore-test frequency;
|
||||
- break-glass drill frequency;
|
||||
- encrypted off-cluster storage.
|
||||
|
||||
High-assurance defaults are fixed reference values: RPO 15 minutes, RTO 4 hours, encrypted off-cluster WAL every 5 minutes with 35-day PITR, daily base backup, monthly restore test, and quarterly break-glass drill.
|
||||
|
||||
## 5. Canonical role map
|
||||
|
||||
```text
|
||||
User
|
||||
↓ objectives, constraints, ratified decisions
|
||||
Interaction Layer
|
||||
↓ workspace/project context; no scheduling authority
|
||||
Portfolio Orchestrator
|
||||
↓ approved mission, cross-project priority/capacity
|
||||
Project Sub-Orchestrator
|
||||
↓ decomposition, DAG, acceptance, release, routing policy, overrides
|
||||
Gateway
|
||||
↓ authenticated/authorized typed commands
|
||||
Project/Task Domain Services
|
||||
↓ transactional state + semantic event + outbox
|
||||
Mechanical Coordinator
|
||||
↓ deterministic eligibility/proposal/lease/fence/retry/quarantine
|
||||
Specialists
|
||||
Planning → Enhance → Coder → Review → conditional SecReview → remediation
|
||||
↓ complete evidence bundle
|
||||
Certifier
|
||||
↓ final pass/reject/escalate; NO merge authority
|
||||
Project Sub-Orchestrator / control plane
|
||||
↓ merge authority after all gates
|
||||
Post-merge validation
|
||||
```
|
||||
|
||||
### Authority table
|
||||
|
||||
| Role/layer | Owns | Explicitly cannot do |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| User | Objectives, constraints, Jason-owned decisions | Direct DB/file authority bypass |
|
||||
| Interaction | Conversation and context resolution | Schedule, approve, lease, certify |
|
||||
| Portfolio Orchestrator | Mission approval, cross-project priority/capacity/global holds | Implement or self-certify specialist work |
|
||||
| Project Sub-Orchestrator | Task decomposition/DAG/acceptance, release to ready, routing policy, overrides, remediation, merge go-ahead | Bypass required independent gates |
|
||||
| Gateway | Identity, tenancy, DTO validation, commands, state-machine enforcement | Accept file edits or client SQL as mutations |
|
||||
| Domain services | Transactional business invariants, semantic events/outbox | Depend on Valkey/files for committed truth |
|
||||
| Mechanical Coordinator | Eligibility, dependencies, proposal, approved routing, lease/fence, heartbeat, retry/quarantine | Invent/alter scope, waive gates, certify, merge |
|
||||
| Specialists | Bounded planning/implementation/review artifacts under a task lease | Modify another lane's owned files or self-approve |
|
||||
| Certifier | Final independent evidence/traceability/gate decision | Merge, close provider issue, release, waive policy |
|
||||
|
||||
## 6. Gate model
|
||||
|
||||
### Mandatory gates
|
||||
|
||||
1. Requirements/contract freeze before parallel implementation.
|
||||
2. P0 schema/authority threat model and tenant isolation review.
|
||||
3. Author and reviewer MUST be different principals/sessions.
|
||||
4. Functional review validates requirements, endpoint registry, concurrency, and negative paths.
|
||||
5. **Mandatory SecReview (`secrev`)** for any auth, authorization, tenant, service-token, secret, database schema/migration, data-integrity, import/cutover, audit, lease/fencing, recovery, or destructive-retirement surface.
|
||||
6. Review findings enter bounded remediation owned by the implementation lane.
|
||||
7. Raising reviewer re-verifies remediation.
|
||||
8. Certifier performs the final independent evidence and traceability gate.
|
||||
9. Merge authority remains with `mos-claude`/Project Sub-Orchestrator control plane after gates pass.
|
||||
10. Post-merge CI and situational validation must be terminal green before closure.
|
||||
|
||||
### Gate outcomes
|
||||
|
||||
- **PASS:** evidence complete; next authority may proceed.
|
||||
- **REJECT:** findings are explicit and route to remediation.
|
||||
- **ESCALATE:** policy/owner decision required; no implicit waiver.
|
||||
|
||||
No role can transform a missing gate into a warning by changing status, editing a projection, or writing Valkey.
|
||||
|
||||
## 7. Slice ownership rules
|
||||
|
||||
1. USC web1 is the sole execution environment; coder2/3/4/5 are independent bounded lanes under Mos.
|
||||
2. Every slice has one named file-tree owner and an explicit IN/OUT boundary in `TASKS.md`.
|
||||
3. Two active slices MUST NOT edit the same source file, migration file, generated snapshot, lockfile, or API contract.
|
||||
4. coder2 exclusively owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, migration journal/meta/tests, then its disjoint recovery-parser/runbook slice. All schema requests serialize through coder2.
|
||||
5. Frozen `contracts/*.v1.ts` are read-only inputs during implementation. Contract changes require Mos approval, a version bump/amendment, and coordinated rebase before work resumes.
|
||||
6. coder3 exclusively owns Gateway DTO/controllers/services and the enumerated `apps/gateway/src/mcp/**` server files. coder4 owns CLI/projection clients and never edits MCP server files. Web consumers use the exact KBN-105 endpoint/DTO freeze.
|
||||
7. coder4 executes one lane order: CLI/projection → pure Coordinator → importer → cutover. The pure Coordinator under `packages/coord` does not load IDs or access DB, Gateway, Valkey, recovery I/O, or web files; coder3 owns the persistence/service adapter.
|
||||
8. Migration/import tooling calls Gateway/migration-only approved ports and does not add a second database model.
|
||||
9. Each lane commits only its owned files and reports any needed cross-slice change as a contract-change request instead of editing another lane's tree.
|
||||
10. Cross-review is mandatory: no lane reviews its own changes. Recommended ring is coder2 ← coder5, coder3 ← coder2, coder4 ← coder3, coder5 ← coder4, followed by independent SecReview where triggered and Certifier final.
|
||||
11. Integration-only edits are a separate serialized slice after component lanes are green; no opportunistic merge-conflict resolution may alter semantics.
|
||||
|
||||
## 8. Delivery phases and exit gates
|
||||
|
||||
### P0 — Canon and authority foundation
|
||||
|
||||
- Publish this canon, frozen schema/ports/health/recovery contracts, threat model, authorization matrix, exact endpoint/DTO registry, concrete current-main field-by-field migration map, and standards amendment.
|
||||
- Build hold remains active until independent author≠reviewer re-review returns GO on health proof/failures, approval binding, fencing, tenant relationships, proposals, migration map, slice ordering/API freeze, recovery validation, and vocabulary alignment.
|
||||
- Exit: no unresolved second writer or contract blocker, tenant boundary frozen, all seven decisions traceable, and independent re-review GO recorded.
|
||||
|
||||
### P1 — Thin native MVP
|
||||
|
||||
- Schema/migration, tenant-safe Gateway, CLI/MCP/projection, writable Kanban/List/Projects, dependencies/readiness/audit.
|
||||
- Exit: same revision across web/CLI/MCP/projection; cross-workspace tests fail closed; generated files cannot mutate state.
|
||||
|
||||
### P2 — Mechanical coordination
|
||||
|
||||
- Agent/session registry, deterministic engine, approval queue, PostgreSQL leases/fencing/checkpoints/outbox, retry/quarantine, operations UI.
|
||||
- Exit: one lease winner, stale tokens rejected, dependencies/approvals enforced, DB/Valkey fault semantics proven, Certifier gate has no merge authority.
|
||||
|
||||
### P3 — Shadow migration and cutover
|
||||
|
||||
- Importer, lineage, reconciliation, reviewer UI, write freeze, final delta, Gateway switch, legacy read-only, stabilization and rollback package.
|
||||
- Exit: signed reconciliation, zero active legacy writers, scoped Gateway identities, imported backlog cannot dispatch accidentally.
|
||||
|
||||
## 9. Evidence required for mission closure
|
||||
|
||||
- Requirement-to-test/evidence matrix.
|
||||
- Schema/migration and N-1 rolling-deploy proof.
|
||||
- Cross-workspace API/repository/import/Coordinator negative tests.
|
||||
- Health-state and fail-closed fault injection.
|
||||
- Valkey-loss/outbox replay and Coordinator restart tests.
|
||||
- Concurrent lease and stale fencing tests.
|
||||
- Endpoint-registry alignment across web/CLI/MCP/Gateway.
|
||||
- Accessible real-Gateway Kanban journeys.
|
||||
- Generated projection tamper/no-import proof.
|
||||
- One-way migration dry-run/apply/verify and field reconciliation.
|
||||
- Author-independent functional review and required SecReview.
|
||||
- Certifier final decision and evidence bundle.
|
||||
- Merged main SHA, terminal green CI, closed linked task/issue, and post-merge situational validation under orchestrator ownership.
|
||||
|
||||
## 10. Change control
|
||||
|
||||
This manifest is derived from the ratified source plan. Any change to SOT authority, workspace tenancy, fixed statuses, Coordinator/Certifier authority, health-state semantics, schema v1, migration direction, or recovery-tier field set is a contract change. Contract changes require Jason/Mos authorization and cannot be inferred by an implementation lane.
|
||||
|
||||
No coder lane may start while the build hold is active. KBN-010 must complete before KBN-100; KBN-105 exact endpoint/DTO freeze must complete before any API consumer implementation.
|
||||
219
docs/native-kanban-sot/SHARED-CONTRACT.md
Normal file
219
docs/native-kanban-sot/SHARED-CONTRACT.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Native Kanban/SOT — Remediated Shared Contract v1
|
||||
|
||||
**Status:** INDEPENDENT REVIEW GO; freezes as v1 when issue #751 canon merges to `main`
|
||||
**Version:** 1.0.0-rc.3
|
||||
**Date:** 2026-07-14
|
||||
**Change authority:** Mosaic control plane/Jason only
|
||||
|
||||
## 1. Authority
|
||||
|
||||
Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied.
|
||||
|
||||
## 2. Health proof and exact failures
|
||||
|
||||
`KanbanHealthResponseV1` is a discriminated union:
|
||||
|
||||
| State | read | write | Capability |
|
||||
| -------------------- | ----: | ----: | --------------------------------------------------- |
|
||||
| `healthy` | true | true | reads; public state still cannot authorize mutation |
|
||||
| `read-only-degraded` | true | false | reads only |
|
||||
| `write-unavailable` | false | false | diagnostics only |
|
||||
|
||||
Every response has `checkedAt`, `validUntil`, `policyRevision`; contradictory booleans fail validation.
|
||||
|
||||
For a mutation, Gateway opens the PostgreSQL transaction, executes the live write probe on that transaction/connection, mints the internal branded `PostgresWriteHealthProofV1`, and revalidates time/policy/transaction identity immediately before mutation. Public REST/MCP/CLI DTOs never accept health/proof fields. Valkey/caller assertions cannot mint proof. Pure Coordinator takes `KanbanEvaluationContextV1`; persistence takes `InternalKanbanMutationContextV1` or probes internally.
|
||||
|
||||
| Case | HTTP | Frozen result | Retry |
|
||||
| ---------------------------- | --------------------------: | ------------------------------------------------------------------- | -------------------- |
|
||||
| degraded write | 503 | `KANBAN_WRITE_HEALTH_UNPROVEN`, `read-only-degraded`, `not_applied` | false |
|
||||
| write unavailable | 503 | `KANBAN_WRITE_UNAVAILABLE`, `write-unavailable`, `not_applied` | false |
|
||||
| version conflict | 409 | `AGGREGATE_VERSION_CONFLICT`, actual version, `not_applied` | false |
|
||||
| timeout/unreachable | timeout/502/504 | `retryable_transport_error`, `unknown` | same idempotency key |
|
||||
| stale fence/session/approval | coordinator rejection union | `not_applied` | false |
|
||||
|
||||
Required negatives: contradictory state, expired/policy-mismatched/wrong-transaction proof, Valkey-only health, forged healthy, and exhaustive non-cross-mapping of 503 vs 502/504/timeout vs 409.
|
||||
|
||||
## 3. Canonical schema invariants
|
||||
|
||||
Complete declaration: `contracts/kanban-schema.v1.ts`.
|
||||
|
||||
- Tables: tenant/identity (`workspaces`, members, teams/members, agents/sessions); planning (`projects`, `milestones`, current-milestone join, `missions`, mission-milestones, `tasks`, normalized tags, dependencies); orchestration (`task_assignments`, durable execution state, leases, checkpoints/evidence); governance (`change_proposals`, immutable artifacts/evidence, events, approvals, outbox, external links).
|
||||
- Task statuses: `backlog | ready | in_progress | blocked | in_review | done | cancelled`.
|
||||
- Assignment states everywhere: `awaiting_approval | policy_pre_authorized | approved | rejected | leased | released | expired | superseded`.
|
||||
- Specialist roles everywhere: `planning | enhance | coder | review | security-review | pr-monitor | certifier`.
|
||||
- Owner uses exactly-one user/team; assignment principal exactly-one user/team/agent; users require active membership; agent/session and all evidence are workspace-bound.
|
||||
- Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations.
|
||||
- Dependency identity is workspace+predecessor+successor independent of type.
|
||||
- Approval evidence and checkpoint evidence are workspace-scoped joins to immutable artifacts, never JSON ID arrays.
|
||||
- Proposal audit links are composite relations: `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` reference `task_events(workspace_id, id)` with RESTRICT deletion.
|
||||
- Assignment is persisted with task/version, exact target/session, expiry/state/policy/proposer/reason. Approval relates to assignment. Lease acquisition accepts IDs, then reloads/locks and validates every relation.
|
||||
- `tasks.fencing_counter` is bigint; locked atomic increment/RETURNING creates a decimal-string lease token. Lease/checkpoint composites bind exact workspace+task+assignment/session+fence.
|
||||
- `task_execution_states` durably records retry/quarantine/exhaustion.
|
||||
- Tags are normalized; legacy `tasks.tags` remains through N-1. Archive is explicit actor/reason/time and does not change lifecycle.
|
||||
- Canonical parents use RESTRICT. Events/checkpoints/artifacts/evidence are INSERT/SELECT-only for application roles. Normal flow archives/cancels; purge is audited break-glass retention work.
|
||||
|
||||
## 4. Outage proposal contract
|
||||
|
||||
`change_proposals` stores workspace, active-member proposer, source-note digest, target/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, proposal version, and submit/accepted event IDs. Both event IDs are workspace-aware composite foreign keys to `task_events(workspace_id, id)`; a bare UUID is never sufficient.
|
||||
|
||||
Submission preallocates the proposal ID. One transaction inserts `change_proposal.submitted` with the proposal workspace, `aggregate_type='change_proposal'`, `aggregate_id=<new proposal ID>`, `previous_version=NULL`, and `new_version=1`, then inserts the proposal referencing that event. Missing, foreign-workspace, wrong-type, or unrelated-proposal events abort the transaction.
|
||||
|
||||
Submit/list/get/accept/reject are explicit Gateway commands. Pending/rejected proposals are inert: no scheduling, dependency/gate satisfaction, or direct target mutation. Acceptance locks proposal+target, obtains fresh transaction-local proof, verifies pending/expected version, invokes the normal command handler, and atomically stores the emitted normal-command event ID. That event must share the proposal workspace, match `target_aggregate_type` and `target_aggregate_id`, use `causation_id=submitted_audit_event_id`, and carry `payload.changeProposalId=<locked proposal ID>`. Missing, foreign-workspace, unrelated-target, unrelated-proposal, or unrelated-command events abort acceptance.
|
||||
|
||||
## 5. Concrete current-main N-1 migration delta
|
||||
|
||||
**Inspected:** `origin/main:packages/db/src/schema.ts` at `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13). It has global teams/no workspace keys, legacy project/mission/task statuses, nullable task project/mission, `tasks.assignee/tags/due_date`, mission JSON/config, duplicated `mission_tasks.status`, legacy agent fields, and separate fleet `backlog` claims.
|
||||
|
||||
Legacy columns remain declared in unified `schema.ts` for expand + full N-1/rollback window. Generation must not infer early drops.
|
||||
|
||||
### 5.1 Ordered phases
|
||||
|
||||
1. **Pre-expand:** N-1 patch stops `mission_tasks.status` as write source; inventory writers; backup/checksum.
|
||||
2. **Expand:** add enums/tables and nullable-first columns; retain legacy declarations/uniques; emit no v1-only status.
|
||||
3. **Backfill:** bootstrap workspace; bounded idempotent cursor/checksum batches; quarantine ambiguous rows.
|
||||
4. **Validate:** no null tenant, cross-project link, ambiguous owner; status/tag/date/config retention; then constraints/NOT NULL.
|
||||
5. **Compatibility:** N-1 reads legacy; same-DB transaction mirrors only unavoidable fields; never file/Valkey dual write.
|
||||
6. **Switch:** stop N-1 writers; Gateway sole command boundary; enable canonical statuses.
|
||||
7. **Contract release:** later release after rollback/N-1; remove compatibility/global uniques/legacy fields.
|
||||
|
||||
### 5.2 New audit/proposal DDL order
|
||||
|
||||
KBN-100 migration DDL must execute in this order:
|
||||
|
||||
1. create `task_events` and its unique `(workspace_id, id)` key;
|
||||
2. create `change_proposals` with nullable acceptance-event ID and required submission-event ID;
|
||||
3. add `change_proposals_workspace_submitted_event_fk` from `(workspace_id, submitted_audit_event_id)` to `task_events(workspace_id, id)` with `ON DELETE RESTRICT`;
|
||||
4. add `change_proposals_workspace_accepted_command_event_fk` from `(workspace_id, accepted_command_audit_event_id)` to the same composite key with `ON DELETE RESTRICT`;
|
||||
5. install application-role immutability privileges and same-transaction semantic validation before enabling proposal commands.
|
||||
|
||||
The submission transaction inserts the event first using a preallocated proposal UUID, then the proposal. Acceptance inserts the normal command event before updating the locked proposal. Neither FK is omitted or replaced by a bare UUID/index check.
|
||||
|
||||
### 5.3 Field map
|
||||
|
||||
| Current | Expand/backfill | N-1 compatibility | Switch/contract |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |
|
||||
| global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later |
|
||||
| `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later |
|
||||
| project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity |
|
||||
| current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority |
|
||||
| nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later |
|
||||
| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review |
|
||||
| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority |
|
||||
| mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off |
|
||||
| mission config/metadata/phase/user | retain all; map known typed policy only | all remain declared | remove only by signed consumer inventory |
|
||||
| nullable `tasks.project_id` | derive explicit/mission project; orphan quarantine | retain nullable read/write during compatibility | canonical required; NOT NULL later |
|
||||
| `tasks.mission_id` | add project-congruent composite | old relation readable | composite authority |
|
||||
| `tasks.status` | canonical: not-started→backlog, in-progress→in_progress, others same | no ready/in_review emission | canonical authority |
|
||||
| `tasks.assignee` | deterministic active user/team/agent assignment; raw value preserved if ambiguous | mirror text only if unambiguous | canonical owner/assignment; remove after no-loss sign-off |
|
||||
| `tasks.tags` JSON | normalize trim/case/dedupe with original digest | transactionally mirror normalized rows | normalized authority; JSON later removed |
|
||||
| `tasks.due_date` | copy exactly to `due_at` | mirror | due_at authority; legacy later |
|
||||
| task common fields | preserve metadata byte-for-byte; add criteria/rank/retry/archive/version/fence | old reads valid | new fields canonical |
|
||||
| `mission_tasks.status` | keep; prohibit as write source; linked status ignored; unlinked becomes task or reject | read-only compatibility value | membership uses task mission; status dropped after no readers |
|
||||
| mission-task notes/PR/user | map to metadata/artifact/event/link/attribution; preserve | read-only | remove after parity |
|
||||
| `agents.status` | add workspace/lifecycle/runtime/roles; status remains presence | retain all legacy fields | lifecycle/roles authority; status may remain telemetry |
|
||||
| agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory |
|
||||
| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization |
|
||||
|
||||
### 5.4 Required migration tests
|
||||
|
||||
Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release.
|
||||
|
||||
Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation.
|
||||
|
||||
## 6. Ownership and Coordinator split
|
||||
|
||||
coder2 solely owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, journal/metadata, and migration tests. No other lane generates migrations. Expand is additive; no drop/rename/narrow; constraints validate before NOT NULL; compatibility is same-DB only; contract is later.
|
||||
|
||||
KBN-200/coder4 owns pure `MechanicalCoordinatorDecisionEngineV1`: complete immutable snapshots in, deterministic eligibility/proposal/retry decisions out; no ID loading, SQL, Gateway, Valkey, proof, persistence, restart I/O, or LLM.
|
||||
|
||||
KBN-210/coder3 owns `MechanicalCoordinatorServicePortV1`: ID loading, locks, fresh proof, assignment/approval persistence, atomic fencing, lease/checkpoint/outbox, Valkey wakes, durable retry/quarantine, and `recoverFromPostgres`. Cycle: load snapshots → pure decision → persist assignment → authoritative approval/policy → acquire by IDs/locks → increment fence → lease → ack/heartbeat/checkpoint → submit to review or durable retry/quarantine. No completion/certification/merge method exists.
|
||||
|
||||
## 7. Exact Gateway/DTO freeze for KBN-105
|
||||
|
||||
### 7.1 Common wire rules
|
||||
|
||||
Base is `/api/v1/workspaces/:workspaceId`. Mutations require header `Idempotency-Key` (1–128 chars). Existing-aggregate mutations also require `If-Match-Version` (positive integer); create and privileged assignment-cycle requests are the only exceptions, while proposal submission carries `expectedTargetVersion` in its body. Body workspace fields are forbidden. Tenant denial follows one 404/403 policy without foreign existence detail.
|
||||
|
||||
```ts
|
||||
interface SuccessEnvelopeV1<T> {
|
||||
contractVersion: '1.0.0';
|
||||
data: T;
|
||||
aggregateRevision: string;
|
||||
correlationId: string;
|
||||
}
|
||||
interface ListEnvelopeV1<T> extends SuccessEnvelopeV1<T[]> {
|
||||
page: { cursor: string | null; nextCursor: string | null; limit: number };
|
||||
}
|
||||
```
|
||||
|
||||
Errors are the exact health/transport/version unions in §2 plus validation/auth/not-found. Public DTOs never expose/accept internal write proof.
|
||||
|
||||
### 7.2 Exact route registry
|
||||
|
||||
| Method/path | Request body/query | Success data |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
|
||||
| `GET /kanban-health` | none | `KanbanHealthResponseV1` |
|
||||
| `GET /projects` | `status,ownerUserId,ownerTeamId,cursor,limit` | project list |
|
||||
| `POST /projects` | `name,key,description,status,priority,ownerUserId XOR ownerTeamId,metadata` | project |
|
||||
| `GET /projects/:projectId` | none | project |
|
||||
| `PATCH /projects/:projectId` | editable create fields + expected header | project |
|
||||
| `POST /projects/:projectId/archive` | `reason` | project |
|
||||
| `GET /tasks` | `projectId,missionId,milestoneId,status,priority,ownerUserId,ownerTeamId,specialistRole,tag,dueState,archived,cursor,limit` | task summary list |
|
||||
| `POST /tasks` | `projectId,missionId?,milestoneId?,parentTaskId?,title,description?,acceptanceCriteria[],status,priority,rank,ownerUserId XOR ownerTeamId,specialistRole?,dueAt?,notBeforeAt?,estimateMinutes?,retryPolicy?,tagIds[],metadata` | task detail |
|
||||
| `GET /tasks/:taskId` | none | task detail including readiness/dependencies/assignment/lease/events |
|
||||
| `PATCH /tasks/:taskId` | editable non-transition fields | task detail |
|
||||
| `POST /tasks/:taskId/transition` | `toStatus,reason?` | task detail |
|
||||
| `POST /tasks/:taskId/move` | `toStatus?,beforeTaskId?,afterTaskId?` | task detail with persisted rank |
|
||||
| `POST /tasks/:taskId/archive` | `reason` | task detail |
|
||||
| `PUT /tasks/:taskId/tags` | `tagIds[]` | task detail |
|
||||
| `POST /tasks/:taskId/dependencies` | `predecessorTaskId,type` | dependency |
|
||||
| `DELETE /tasks/:taskId/dependencies/:predecessorTaskId` | no body | deleted dependency ID |
|
||||
| `GET /tasks/:taskId/events` | `cursor,limit` | event list |
|
||||
| `GET /tags` | `query,cursor,limit` | tag list |
|
||||
| `POST /tags` | `name,color?` | tag |
|
||||
| `GET /change-proposals` | `state,targetType,targetId,cursor,limit` | proposal list |
|
||||
| `POST /change-proposals` | `sourceNoteDigest,targetType,targetId,expectedTargetVersion,commandType,commandPayload` | inert proposal |
|
||||
| `GET /change-proposals/:proposalId` | none | proposal |
|
||||
| `POST /change-proposals/:proposalId/accept` | `reason` | proposal + normal command result |
|
||||
| `POST /change-proposals/:proposalId/reject` | `reason` | proposal |
|
||||
| `GET /coordinator/eligibility` | `projectId?,missionId?,cursor,limit` | `EligibilityDecisionV1[]` |
|
||||
| `POST /coordinator/assignment-cycles` | `limit` | assignment proposals; privileged internal |
|
||||
| `POST /coordinator/assignments/:assignmentId/approve` | `decision,reason,policyRevision,artifactIds[]` | approval decision |
|
||||
| `POST /coordinator/leases/acquire` | `taskId,assignmentId,approvalDecisionId,targetSessionId,leaseTtlSeconds` | lease with decimal-string fence |
|
||||
| `POST /coordinator/leases/:leaseId/ack` | `taskId,sessionId,fencingToken` | lease |
|
||||
| `POST /coordinator/leases/:leaseId/heartbeat` | `taskId,sessionId,fencingToken,extendSeconds` | lease |
|
||||
| `POST /coordinator/leases/:leaseId/checkpoints` | `taskId,sessionId,fencingToken,sequence,resumableSummary,artifactIds[],contextUsagePercent` | checkpoint |
|
||||
| `POST /coordinator/leases/:leaseId/submit-review` | `taskId,sessionId,fencingToken,artifactIds[],summary` | task in `in_review` |
|
||||
|
||||
All Coordinator mutations except human approval are service-identity-only. Generic task PATCH cannot perform claim/heartbeat/checkpoint/review/certification/completion shortcuts. Completion after certification uses a separately gated lifecycle command owned by the Portfolio/Sub-Orchestrator flow, not the Coordinator.
|
||||
|
||||
### 7.3 DTO invariants
|
||||
|
||||
Task summary/detail use exact schema vocabularies, owner union, `version: number`, `fencingCounter: string`, explicit `archivedAt/by/reason`, normalized tags, computed readiness, and separate assignment/lease. Assignment DTO includes one persisted ID, task/version, exact principal/agent/session, role, state, expiry, policy, proposer/reason. Lease/checkpoint DTOs serialize every fence as decimal string. Proposal DTO exposes no hidden write authority.
|
||||
|
||||
### 7.4 MCP ownership and mapping
|
||||
|
||||
coder3 exclusively owns:
|
||||
|
||||
- `apps/gateway/src/mcp/mcp.dto.ts`
|
||||
- `mcp.controller.ts`
|
||||
- `mcp.service.ts`
|
||||
- `mcp.module.ts`
|
||||
- `mcp.tokens.ts`
|
||||
- `mcp.service.spec.ts`
|
||||
|
||||
MCP tools are thin maps: `mosaic_projects_{list,get,create,update,archive}`, `mosaic_tasks_{list,get,create,update,transition,move,archive,set_tags,add_dependency,remove_dependency}`, and `mosaic_change_proposals_{list,get,submit,accept,reject}` to the exact routes above. coder4 owns CLI/projection clients only and must not edit Gateway MCP files.
|
||||
|
||||
KBN-105 publishes route+DTO fixture digest before KBN-110/120/130. Every web/CLI/MCP call must match this registry and the generated client.
|
||||
|
||||
## 8. Recovery contract and bounded delivery slice
|
||||
|
||||
Runtime must invoke normative `validateRecoveryPostureV1`; JSON Schema alone is insufficient. It rejects unknown fields, PITR/WAL mismatch, RPO better than mechanism, unsafe storage, and weakened High-assurance. High-assurance is RPO 15m/RTO 4h, WAL ≤5m, PITR ≥35d, base ≤24h, restore test ≤30d, break-glass ≤90d, encrypted separate-failure-domain storage.
|
||||
|
||||
KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recovery runbook. It wires parser/refinement, override audit, mechanism assertions, restore test, and break-glass evidence. Any deployment manifest is separately enumerated and Mos-serialized. Recovery config has no SOT/gate/Coordinator authority fields.
|
||||
|
||||
## 9. Integration, security, and hold
|
||||
|
||||
Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
|
||||
|
||||
The build hold remains active until independent re-review reports GO for KCR-001–016. Mos alone releases waves and serializes integration roots.
|
||||
263
docs/native-kanban-sot/TASKS.md
Normal file
263
docs/native-kanban-sot/TASKS.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Native Kanban/SOT P0–P3 — Dependency-Ordered Build Slices
|
||||
|
||||
**Status:** CANON INDEPENDENTLY APPROVED; PUBLICATION IN PROGRESS
|
||||
**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes
|
||||
**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files
|
||||
**Implementation hold:** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete.
|
||||
|
||||
> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported.
|
||||
|
||||
## Execution invariants
|
||||
|
||||
- PostgreSQL is the sole writable SOT; current-main Drizzle is the persistence foundation.
|
||||
- Mutations require fresh internal PostgreSQL transaction-local write proof and fail closed otherwise.
|
||||
- Public health DTOs, Valkey, files, browser state, providers, and outage notes cannot authorize writes.
|
||||
- Outage notes return only through attributable `change_proposals`; proposal acceptance executes the normal command.
|
||||
- Mechanical Coordinator is non-LLM and cannot invent scope, waive gates, certify, or merge.
|
||||
- Certifier is final independent gate with no merge authority.
|
||||
- Workspace is the hard tenant. Project hierarchy is project-congruent. Assignment, approval, lease, fence, checkpoint, and evidence are relationally bound.
|
||||
- Recovery tiers change recovery posture only.
|
||||
|
||||
## 1. Collision-free ownership
|
||||
|
||||
| USC lane | Exclusive ownership | Must not edit |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| **coder2 — schema/recovery** | `packages/db/src/schema.ts`; `packages/db/drizzle/**`; DB tests; `packages/config/src/recovery-posture.ts`; `packages/config/src/recovery-posture.spec.ts`; `docs/runbooks/kanban-postgres-recovery.md` | Gateway, Brain repositories, Coordinator, web, CLI/importer |
|
||||
| **coder3 — domain/Gateway/MCP server** | Kanban repositories under `packages/brain/src/`; Gateway workspace/project/mission/milestone/task/kanban/health/coord modules; **exact MCP files:** `apps/gateway/src/mcp/mcp.dto.ts`, `mcp.controller.ts`, `mcp.service.ts`, `mcp.module.ts`, `mcp.tokens.ts`, `mcp.service.spec.ts`; Gateway root wiring/tests | DB schema/migrations, `packages/coord`, web, CLI/importer |
|
||||
| **coder4 — CLI → pure Coordinator → migration tooling** | In this one fixed lane order: KBN-120 (`packages/mosaic` CLI/projection) → KBN-200 (`packages/coord/src/mechanical/**`) → KBN-300/320 (`scripts/kanban-migration/**`) | DB, Gateway/MCP server, web |
|
||||
| **coder5 — web** | `apps/web/src/app/(dashboard)/{tasks,projects}/**`; `apps/web/src/components/{tasks,projects}/**`; Kanban web API/types; later Coordinator/migration-review routes | DB, Gateway, Coordinator, CLI/importer |
|
||||
| **Mos — publication/integration** | Contract amendments, exact endpoint registry publication, serialized root exports/manifests/lockfiles, integration gates | Active lane feature files |
|
||||
|
||||
Shared roots, package exports/manifests, lockfiles, and generated artifacts are integration-serialized. Contract changes stop affected lanes and require Mos approval.
|
||||
|
||||
## 2. Parallelization legend
|
||||
|
||||
- **SERIAL:** prerequisite must be complete and reviewed.
|
||||
- **PARALLEL-GROUP:** disjoint files and exact frozen contract permit concurrent work.
|
||||
- **LANE-SERIAL:** one lane's stated order cannot change.
|
||||
- **INTEGRATION-SERIAL:** component heads green first; semantic findings return to owner.
|
||||
|
||||
## 3. Corrected dependency graph
|
||||
|
||||
```text
|
||||
KBN-000 canon remediation
|
||||
-> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE)
|
||||
-> KBN-100 schema + concrete N-1 migration implementation
|
||||
├─ KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL)
|
||||
│ ├─ KBN-110 domain + Gateway + MCP server implementation
|
||||
│ ├─ KBN-120 CLI/projection implementation [coder4 first]
|
||||
│ └─ KBN-130 web MVP implementation
|
||||
└─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
|
||||
KBN-110 + KBN-120 + KBN-130 + KBN-115
|
||||
-> KBN-140 P1 integration/SIT
|
||||
-> KBN-200 pure decision engine [coder4 after KBN-120]
|
||||
-> KBN-210 persistence/service adapter + approval/lease binding
|
||||
-> KBN-220 Coordinator operations UI
|
||||
-> KBN-230 P2 concurrency/fault/gate integration
|
||||
KBN-230
|
||||
-> KBN-300 importer dry-run/apply/verify [coder4 after KBN-200]
|
||||
├─ KBN-310 migration reviewer UI
|
||||
└─ KBN-320 cutover/rollback tooling [coder4 after KBN-300]
|
||||
KBN-310 + KBN-320
|
||||
-> KBN-330 rehearsal/reconciliation
|
||||
-> KBN-340 owner-gated cutover/stabilization
|
||||
```
|
||||
|
||||
No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320.
|
||||
|
||||
## 4. P0 — Canon, threat gate, schema, and exact API freeze
|
||||
|
||||
### KBN-000 — Remediate and publish canon
|
||||
|
||||
- **Status:** COMPLETE — PR #752 squash-merged as `49e8a54`; issue #751 closed; post-merge pipeline #1798 terminal success.
|
||||
- **Owner:** Mosaic publication control plane.
|
||||
- **Mode:** SERIAL; completed.
|
||||
- **IN:** Resolve KCR-001–016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review.
|
||||
- **OUT:** Feature implementation.
|
||||
- **Depends on:** none.
|
||||
- **Contract surfaces:** all canon.
|
||||
- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO; Ultron GO; terminal-green CI.
|
||||
|
||||
### KBN-010 — Threat, authorization, and constraint-impact gate
|
||||
|
||||
- **Status:** IN PROGRESS — issue [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753).
|
||||
- **Owner:** `kbn-coder3`; independent `secrev`.
|
||||
- **Mode:** SERIAL prerequisite of KBN-100.
|
||||
- **Exclusive files:** `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and task scratchpad only.
|
||||
- **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage.
|
||||
- **OUT:** Runtime/schema edits.
|
||||
- **Depends on:** KBN-000 independent re-review GO.
|
||||
- **Contract surfaces:** schema constraints, health proof, exact errors, command-family authorization.
|
||||
- **Evidence:** signed constraint-impact matrix; no unresolved schema-impact finding; SecReview pass.
|
||||
|
||||
### KBN-100 — Unified Drizzle schema and concrete N-1 migration
|
||||
|
||||
- **Owner:** **coder2**.
|
||||
- **Mode:** SERIAL.
|
||||
- **Exclusive files:** `packages/db/src/schema.ts`, `packages/db/drizzle/**`, DB tests.
|
||||
- **IN:** All frozen tables/joins/enums; workspace/project-congruent constraints; owners/principals; tags/archive; change proposals with both workspace-aware task-event composite FKs and frozen event-before-proposal DDL order; assignment approvals; durable execution/quarantine; monotonic bigint fence; exact checkpoint/evidence joins; RESTRICT/immutability; concrete current-main expand/backfill/switch/contract map.
|
||||
- **OUT:** Repositories, Gateway, Coordinator behavior, UI, importer.
|
||||
- **Depends on:** **KBN-010 completed**.
|
||||
- **Contract surfaces:** `kanban-schema.v1.ts`; SHARED-CONTRACT current-main delta map.
|
||||
- **Evidence:** reviewed SQL; empty/prod-shape/partial-resume/rollback tests; N-1 app safety; legacy columns remain declared; workspace/project mismatch negatives; proposal event-FK missing/foreign-workspace tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; SecReview.
|
||||
|
||||
### KBN-105 — Exact Gateway/MCP endpoint, DTO, and error freeze
|
||||
|
||||
- **Owner:** Mos + coder3 contract author; independent endpoint-alignment reviewer.
|
||||
- **Mode:** SERIAL after KBN-100; prerequisite for KBN-110/120/130.
|
||||
- **Exclusive files:** canonical endpoint-registry/DTO contract docs; no implementation.
|
||||
- **IN:** Exact routes and methods from SHARED-CONTRACT §8; request/success/error fields; status codes; pagination/filter/revision envelopes; idempotency/expected-version headers/fields; proposal commands; health proof exclusion from public DTOs; MCP tool-to-route map.
|
||||
- **OUT:** Controller/service/client implementation.
|
||||
- **Depends on:** KBN-100.
|
||||
- **Contract surfaces:** health/error unions; schema IDs/statuses; Gateway DTO freeze.
|
||||
- **Evidence:** every FE/CLI/MCP call maps 1:1 to a route; 503/502-504/409 non-cross-map fixtures; contract digest published.
|
||||
|
||||
### KBN-115 — Recovery posture parser, mechanisms, and evidence
|
||||
|
||||
- **Owner:** **coder2**, lane-serial after KBN-100.
|
||||
- **Mode:** PARALLEL with KBN-110/120/130 after KBN-105.
|
||||
- **Exclusive files:** `packages/config/src/recovery-posture.ts`, `.spec.ts`, `docs/runbooks/kanban-postgres-recovery.md`; deployment-specific backup manifest changes are a separately enumerated Mos integration patch.
|
||||
- **IN:** Wire normative `validateRecoveryPostureV1`; override audit; backup/WAL/PITR mechanism assertions; off-cluster encryption/failure-domain checks; restore and break-glass evidence procedure.
|
||||
- **OUT:** SOT/gate/Coordinator policy knobs; DB business schema.
|
||||
- **Depends on:** KBN-100, KBN-105.
|
||||
- **Contract surfaces:** `recovery-posture.v1.ts` only.
|
||||
- **Evidence:** impossible-combination tests; High-assurance weakening tests; selected-tier mechanism verification; restore and break-glass evidence; SecReview.
|
||||
|
||||
## 5. P1 — Thin native MVP
|
||||
|
||||
### KBN-110 — Workspace-safe domain, Gateway, MCP server, and proposal commands
|
||||
|
||||
- **Owner:** **coder3**.
|
||||
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
|
||||
- **Exclusive files:** ownership map, including all exact MCP server files listed there.
|
||||
- **IN:** Workspace-safe repositories; project/task/dependency/tag/archive CRUD; transitions; exact owners; assignment/approval/link/artifact queries; submit/query/accept/reject change proposals; health endpoint; internal write-proof mint/revalidation; event/outbox atomicity; frozen DTOs/routes.
|
||||
- **OUT:** Scheduling algorithm, web, CLI, DB schema.
|
||||
- **Depends on:** KBN-100, KBN-105.
|
||||
- **Contract surfaces:** all four TypeScript contracts and exact registry.
|
||||
- **Evidence:** DTO/service/controller/integration tests; active-membership and no-oracle negatives; proposal cannot mutate directly; submission event is the new proposal's exact `change_proposal.submitted` event; acceptance links the executed normal command for the locked proposal and same workspace/target; missing, foreign-workspace, unrelated-proposal/target/command event negatives; exact failure mapping; endpoint registry; SecReview.
|
||||
|
||||
### KBN-120 — CLI, MCP client mapping, and generated projection
|
||||
|
||||
- **Owner:** **coder4**; first coder4 slice.
|
||||
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
|
||||
- **Exclusive files:** `packages/mosaic/src/commands/{kanban,tasks,projects}.ts`; `packages/mosaic/src/projections/**`; tests. **No `apps/gateway/src/mcp/**` edits.\*\*
|
||||
- **IN:** Frozen query/mutation routes; proposal commands; compact context; generated `TASKS.md`; deliberate denial/transport/conflict handling.
|
||||
- **OUT:** Gateway/MCP server, file importer, raw SQL/Valkey, Coordinator.
|
||||
- **Depends on:** KBN-105; runtime integration later requires KBN-110.
|
||||
- **Evidence:** contract fixtures; same revision; no import parser; same idempotency key on transport retry; 503 never auto-retried.
|
||||
|
||||
### KBN-130 — Writable Kanban/List and minimal Projects UI
|
||||
|
||||
- **Owner:** **coder5**.
|
||||
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
|
||||
- **Exclusive files:** web ownership map.
|
||||
- **IN:** Workspace context; projects; tasks; tags; explicit archive; detail; accessible move/reorder; filters; dependency/readiness; owner/assignment/lease; audit; proposal visibility; conflict/loading/error/reconnect.
|
||||
- **OUT:** Gateway/schema, Coordinator operations UI, migration UI.
|
||||
- **Depends on:** KBN-105; runtime integration later requires KBN-110.
|
||||
- **Evidence:** frozen contract mocks; real-Gateway journeys; keyboard/non-drag; tags/archive semantics; no-oracle tenant negatives; 503/transport/409 distinct UI.
|
||||
|
||||
### KBN-140 — P1 integration and situational gate
|
||||
|
||||
- **Owner:** Mos integration; independent reviewer/SecReview/Certifier.
|
||||
- **Mode:** INTEGRATION-SERIAL.
|
||||
- **IN:** KBN-110/120/130/115; unavoidable root exports only.
|
||||
- **OUT:** P2 behavior.
|
||||
- **Depends on:** KBN-110, KBN-120, KBN-130, KBN-115.
|
||||
- **Evidence:** clean migration; web/CLI/MCP/projection revision parity; forged/expired health negatives; change-proposal event-chain success plus missing/foreign/unrelated-event negatives; tag/archive; tenant negatives; endpoint registry; author-independent review; Certifier pass.
|
||||
|
||||
## 6. P2 — Mechanical Coordinator
|
||||
|
||||
### KBN-200 — Pure deterministic decision engine
|
||||
|
||||
- **Owner:** **coder4**; second coder4 slice, strictly after KBN-120.
|
||||
- **Mode:** SERIAL in coder4 lane.
|
||||
- **Exclusive files:** `packages/coord/src/mechanical/**` and pure tests.
|
||||
- **IN:** `MechanicalCoordinatorDecisionEngineV1`; complete immutable snapshots; eligibility/explanation; fairness/order; capability matching; expiry/retry/quarantine decisions.
|
||||
- **OUT:** ID loading, PostgreSQL, Drizzle, Gateway, Valkey, health-proof minting, persistence, `recoverFromPostgres`, LLM calls.
|
||||
- **Depends on:** KBN-140 (or Mos may release after KBN-120 + frozen types if no P1 semantic risk remains).
|
||||
- **Evidence:** deterministic/property tests; snapshot completeness; no I/O/model imports; no authority methods.
|
||||
|
||||
### KBN-210 — Coordinator persistence/service adapter and approval-bound leases
|
||||
|
||||
- **Owner:** **coder3**.
|
||||
- **Mode:** SERIAL after KBN-200.
|
||||
- **Exclusive files:** Gateway `coord` and repositories.
|
||||
- **IN:** `MechanicalCoordinatorServicePortV1`; snapshot loading; proposal persistence; manual/versioned policy approval; acquire by IDs; reload+lock task/assignment/approval/session; fresh txn-local write proof; atomic task fence increment; lease/ack/heartbeat/checkpoint/submit; durable retry/quarantine; outbox/Valkey wake; restart recovery.
|
||||
- **OUT:** Pure algorithm, UI, DB schema.
|
||||
- **Depends on:** KBN-110, KBN-200.
|
||||
- **Evidence:** forged/stale approval rejection; target/session/version/expiry/policy checks; concurrent monotonic fences; same-workspace mismatch negatives; bigint precision; stale worker rejection; DB/Valkey faults; SecReview.
|
||||
|
||||
### KBN-220 — Coordinator operations UI
|
||||
|
||||
- **Owner:** **coder5**.
|
||||
- **Mode:** after KBN-210 exact DTO freeze.
|
||||
- **IN:** Roster; eligibility; persisted assignment state; approvals/overrides; exact lease/fence; durable retry/quarantine; role/gate/Certifier visibility.
|
||||
- **OUT:** Scheduling decisions, schema, merge control for Certifier.
|
||||
- **Depends on:** KBN-210.
|
||||
- **Evidence:** authorized journeys; reason required; stale refresh; no Certifier merge; endpoint alignment/accessibility.
|
||||
|
||||
### KBN-230 — P2 concurrency/fault/gate integration
|
||||
|
||||
- **Owner:** Mos integration; independent reviewer/SecReview/Certifier.
|
||||
- **Mode:** INTEGRATION-SERIAL.
|
||||
- **Depends on:** KBN-200, KBN-210, KBN-220.
|
||||
- **Evidence:** one lease; monotonic fences; exact relational mismatches rejected; expired proof; forged healthy; approval binding; restart; durable quarantine; outbox recovery; author≠reviewer; Certifier final/no merge.
|
||||
|
||||
## 7. P3 — Shadow migration and cutover
|
||||
|
||||
### KBN-300 — One-way importer dry-run/apply/verify
|
||||
|
||||
- **Owner:** **coder4**; third coder4 slice.
|
||||
- **Mode:** after KBN-230.
|
||||
- **Exclusive files:** `scripts/kanban-migration/import/**`.
|
||||
- **IN:** Immutable jarvis-brain/Vikunja snapshots; deterministic mapping; source digest/lineage; Gateway writes; rejects; no dispatch.
|
||||
- **OUT:** Bidirectional sync, direct DB/file canonical writes, unrelated brain data.
|
||||
- **Depends on:** KBN-230.
|
||||
- **Evidence:** idempotency; counts/fields; malformed/foreign rejects; no dispatch; SecReview.
|
||||
|
||||
### KBN-310 — Shadow reviewer UI
|
||||
|
||||
- **Owner:** **coder5**.
|
||||
- **Mode:** PARALLEL-GROUP P3-A after KBN-300 report freeze.
|
||||
- **IN:** Read-only counts/diffs/rejects/lineage/sign-off.
|
||||
- **OUT:** Apply/cutover mutations.
|
||||
- **Depends on:** KBN-300.
|
||||
- **Evidence:** read-only and tenant tests; pagination/accessibility.
|
||||
|
||||
### KBN-320 — Cutover/rollback tooling
|
||||
|
||||
- **Owner:** **coder4**; fourth coder4 slice, after KBN-300.
|
||||
- **Mode:** PARALLEL-GROUP P3-A with KBN-310.
|
||||
- **Exclusive files:** `scripts/kanban-migration/cutover/**`.
|
||||
- **IN:** Freeze assertion; backup/checksum; final delta; client switch; legacy writer/credential shutdown; rollback delta; stabilization.
|
||||
- **OUT:** Destructive deletion, reverse sync, ungated production execution.
|
||||
- **Depends on:** KBN-300.
|
||||
- **Evidence:** fail-safe rehearsal; no dual writer; rollback authority; SecReview.
|
||||
|
||||
### KBN-330 — Migration rehearsal/reconciliation
|
||||
|
||||
- **Owner:** Mos + coder4 support + independent data reviewer.
|
||||
- **Mode:** INTEGRATION-SERIAL.
|
||||
- **Depends on:** KBN-310, KBN-320.
|
||||
- **Evidence:** signed exceptions; selected-tier restore; backlog hold; no legacy changes; Certifier readiness.
|
||||
|
||||
### KBN-340 — Final cutover/stabilization
|
||||
|
||||
- **Owner:** Mos/control plane; owner-gated operation.
|
||||
- **Mode:** SERIAL.
|
||||
- **Depends on:** KBN-330 PASS and Jason authorization.
|
||||
- **Evidence:** no legacy writer; scoped Gateway identities; no accidental dispatch; terminal green health/CI; Certifier evidence; owner retirement approval.
|
||||
|
||||
## 8. Consistent USC wave schedule
|
||||
|
||||
| Wave | coder2 | coder3 | coder4 | coder5 |
|
||||
| ---- | ------------------------- | -------------------------------------- | ------------------------------ | ------------------------------ |
|
||||
| 0 | Wait | **KBN-010** | Wait | Wait |
|
||||
| 1 | **KBN-100** | Review constraint implementation | Wait | Wait |
|
||||
| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 |
|
||||
| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 |
|
||||
| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** |
|
||||
| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** |
|
||||
|
||||
Mos alone releases slices and lifts the build hold after independent re-review GO.
|
||||
206
docs/native-kanban-sot/contracts/health-state.v1.ts
Normal file
206
docs/native-kanban-sot/contracts/health-state.v1.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Mosaic Native Kanban — frozen health/error contract v1.
|
||||
* Publication contract only; no runtime implementation is included here.
|
||||
*
|
||||
* PostgreSQL is the sole writable SOT. Public health DTOs are observations,
|
||||
* never write authority. Only an internal transaction-local proof produced by
|
||||
* the PostgreSQL adapter may authorize a mutation.
|
||||
*/
|
||||
|
||||
export const KANBAN_CONTRACT_VERSION = '1.0.0' as const;
|
||||
|
||||
export const kanbanHealthStates = ['healthy', 'read-only-degraded', 'write-unavailable'] as const;
|
||||
export type KanbanHealthState = (typeof kanbanHealthStates)[number];
|
||||
|
||||
interface KanbanHealthBaseV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
checkedAt: string;
|
||||
/** Observation expires at this RFC 3339 instant; it still never authorizes writes. */
|
||||
validUntil: string;
|
||||
policyRevision: string;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface HealthyKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
|
||||
state: 'healthy';
|
||||
readHealthProven: true;
|
||||
writeHealthProven: true;
|
||||
}
|
||||
|
||||
export interface ReadOnlyDegradedKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
|
||||
state: 'read-only-degraded';
|
||||
readHealthProven: true;
|
||||
writeHealthProven: false;
|
||||
}
|
||||
|
||||
export interface WriteUnavailableKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
|
||||
state: 'write-unavailable';
|
||||
readHealthProven: false;
|
||||
writeHealthProven: false;
|
||||
}
|
||||
|
||||
/** Public, discriminated observation. Contradictory combinations are unrepresentable. */
|
||||
export type KanbanHealthResponseV1 =
|
||||
| HealthyKanbanHealthResponseV1
|
||||
| ReadOnlyDegradedKanbanHealthResponseV1
|
||||
| WriteUnavailableKanbanHealthResponseV1;
|
||||
|
||||
/** Pure evaluation context. It cannot authorize a mutation. */
|
||||
export interface KanbanEvaluationContextV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
workspaceId: string;
|
||||
correlationId: string;
|
||||
now: string;
|
||||
policyRevision: string;
|
||||
observedHealth: KanbanHealthResponseV1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-exported brand: public DTO deserialization cannot construct this type.
|
||||
* The PostgreSQL adapter mints it only after a fresh write probe inside the same
|
||||
* transaction and validates checkedAt <= now < validUntil and policy revision.
|
||||
*/
|
||||
declare const postgresWriteHealthProofBrand: unique symbol;
|
||||
export interface PostgresWriteHealthProofV1 {
|
||||
readonly [postgresWriteHealthProofBrand]: true;
|
||||
readonly source: 'postgres-transaction-local-write-probe';
|
||||
readonly transactionId: string;
|
||||
readonly checkedAt: string;
|
||||
readonly validUntil: string;
|
||||
readonly policyRevision: string;
|
||||
}
|
||||
|
||||
/** Internal mutation context; MUST NOT appear in REST/MCP/CLI request DTOs. */
|
||||
export interface InternalKanbanMutationContextV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
workspaceId: string;
|
||||
correlationId: string;
|
||||
causationId?: string;
|
||||
idempotencyKey: string;
|
||||
now: string;
|
||||
expectedPolicyRevision: string;
|
||||
writeProof: PostgresWriteHealthProofV1;
|
||||
}
|
||||
|
||||
interface MutationFailureBaseV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
retryable: false;
|
||||
requestOutcome: 'not_applied';
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** KCR-016: code/state pairing is exact and cannot cross-map. */
|
||||
export interface ReadOnlyWriteHealthDenialV1 extends MutationFailureBaseV1 {
|
||||
kind: 'deliberate_fail_closed_denial';
|
||||
code: 'KANBAN_WRITE_HEALTH_UNPROVEN';
|
||||
healthState: 'read-only-degraded';
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface WriteUnavailableDenialV1 extends MutationFailureBaseV1 {
|
||||
kind: 'deliberate_fail_closed_denial';
|
||||
code: 'KANBAN_WRITE_UNAVAILABLE';
|
||||
healthState: 'write-unavailable';
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export type DeliberateWriteDenialV1 = ReadOnlyWriteHealthDenialV1 | WriteUnavailableDenialV1;
|
||||
|
||||
export const transportErrorCodes = [
|
||||
'GATEWAY_UNREACHABLE',
|
||||
'GATEWAY_TIMEOUT',
|
||||
'UPSTREAM_BAD_GATEWAY',
|
||||
] as const;
|
||||
export type TransportErrorCode = (typeof transportErrorCodes)[number];
|
||||
|
||||
/** Client-normalized transport uncertainty; never an authoritative 503 body. */
|
||||
export interface RetryableTransportErrorV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
kind: 'retryable_transport_error';
|
||||
code: TransportErrorCode;
|
||||
retryable: true;
|
||||
requestOutcome: 'unknown';
|
||||
/** Retry MUST reuse this exact key. */
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface VersionConflictV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
kind: 'version_conflict';
|
||||
code: 'AGGREGATE_VERSION_CONFLICT';
|
||||
retryable: false;
|
||||
requestOutcome: 'not_applied';
|
||||
aggregateType: 'project' | 'mission' | 'milestone' | 'task' | 'change_proposal';
|
||||
aggregateId: string;
|
||||
expectedVersion: number;
|
||||
actualVersion: number;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type KanbanMutationFailureV1 =
|
||||
| DeliberateWriteDenialV1
|
||||
| RetryableTransportErrorV1
|
||||
| VersionConflictV1;
|
||||
|
||||
export const kanbanHealthCapabilities: Readonly<
|
||||
Record<KanbanHealthState, { canonicalReads: boolean; mutations: boolean }>
|
||||
> = {
|
||||
healthy: { canonicalReads: true, mutations: true },
|
||||
'read-only-degraded': { canonicalReads: true, mutations: false },
|
||||
'write-unavailable': { canonicalReads: false, mutations: false },
|
||||
};
|
||||
|
||||
/** Exact HTTP/error normalization freeze; 503, transport, and 409 cannot cross-map. */
|
||||
export const kanbanFailureHttpMapV1 = {
|
||||
KANBAN_WRITE_HEALTH_UNPROVEN: {
|
||||
httpStatus: 503,
|
||||
kind: 'deliberate_fail_closed_denial',
|
||||
requestOutcome: 'not_applied',
|
||||
retryable: false,
|
||||
},
|
||||
KANBAN_WRITE_UNAVAILABLE: {
|
||||
httpStatus: 503,
|
||||
kind: 'deliberate_fail_closed_denial',
|
||||
requestOutcome: 'not_applied',
|
||||
retryable: false,
|
||||
},
|
||||
AGGREGATE_VERSION_CONFLICT: {
|
||||
httpStatus: 409,
|
||||
kind: 'version_conflict',
|
||||
requestOutcome: 'not_applied',
|
||||
retryable: false,
|
||||
},
|
||||
GATEWAY_UNREACHABLE: {
|
||||
httpStatus: 502,
|
||||
kind: 'retryable_transport_error',
|
||||
requestOutcome: 'unknown',
|
||||
retryable: true,
|
||||
},
|
||||
GATEWAY_TIMEOUT: {
|
||||
httpStatus: 504,
|
||||
kind: 'retryable_transport_error',
|
||||
requestOutcome: 'unknown',
|
||||
retryable: true,
|
||||
},
|
||||
UPSTREAM_BAD_GATEWAY: {
|
||||
httpStatus: 502,
|
||||
kind: 'retryable_transport_error',
|
||||
requestOutcome: 'unknown',
|
||||
retryable: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Required negative contract tests:
|
||||
* - contradictory state/proof booleans fail type/schema validation;
|
||||
* - expired internal proof and policy mismatch deny before mutation;
|
||||
* - Valkey-only liveness cannot mint PostgresWriteHealthProofV1;
|
||||
* - public/caller-forged `healthy` cannot enter InternalKanbanMutationContextV1;
|
||||
* - authoritative 503, transport 502/504/timeout, and 409 mappings are exhaustive.
|
||||
*/
|
||||
1294
docs/native-kanban-sot/contracts/kanban-schema.v1.ts
Normal file
1294
docs/native-kanban-sot/contracts/kanban-schema.v1.ts
Normal file
File diff suppressed because it is too large
Load Diff
419
docs/native-kanban-sot/contracts/mechanical-coordinator.v1.ts
Normal file
419
docs/native-kanban-sot/contracts/mechanical-coordinator.v1.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Mosaic Native Kanban — frozen Mechanical Coordinator contracts v1.
|
||||
*
|
||||
* The pure decision engine and persistence/orchestration service are separate.
|
||||
* Neither surface can create scope, edit acceptance, waive gates, certify,
|
||||
* merge, release a deployment, or close a provider issue.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DeliberateWriteDenialV1,
|
||||
InternalKanbanMutationContextV1,
|
||||
KanbanEvaluationContextV1,
|
||||
KanbanMutationFailureV1,
|
||||
RetryableTransportErrorV1,
|
||||
VersionConflictV1,
|
||||
} from './health-state.v1.js';
|
||||
|
||||
export const COORDINATOR_CONTRACT_VERSION = '1.0.0' as const;
|
||||
export type Uuid = string;
|
||||
export type IsoTimestamp = string;
|
||||
/** PostgreSQL bigint-safe decimal string; never a JavaScript number. */
|
||||
export type FencingTokenV1 = string;
|
||||
|
||||
export const specialistRoles = [
|
||||
'planning',
|
||||
'enhance',
|
||||
'coder',
|
||||
'review',
|
||||
'security-review',
|
||||
'pr-monitor',
|
||||
'certifier',
|
||||
] as const;
|
||||
export type SpecialistRole = (typeof specialistRoles)[number];
|
||||
|
||||
/** One vocabulary shared with task_assignment_state_v1 in the Drizzle schema. */
|
||||
export const assignmentStates = [
|
||||
'awaiting_approval',
|
||||
'policy_pre_authorized',
|
||||
'approved',
|
||||
'rejected',
|
||||
'leased',
|
||||
'released',
|
||||
'expired',
|
||||
'superseded',
|
||||
] as const;
|
||||
export type AssignmentStateV1 = (typeof assignmentStates)[number];
|
||||
|
||||
export const readinessStates = [
|
||||
'dependency-gated',
|
||||
'schedule-gated',
|
||||
'policy-gated',
|
||||
'lease-available',
|
||||
'leased',
|
||||
'retry-delayed',
|
||||
'exhausted',
|
||||
'quarantined',
|
||||
] as const;
|
||||
export type ReadinessState = (typeof readinessStates)[number];
|
||||
|
||||
export interface RetryStateSnapshotV1 {
|
||||
disposition: 'available' | 'retry_delayed' | 'quarantined' | 'exhausted';
|
||||
attemptCount: number;
|
||||
maxAttempts: number;
|
||||
nextEligibleAt: IsoTimestamp | null;
|
||||
idempotent: boolean;
|
||||
terminalReason: string | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface TaskEligibilitySnapshotV1 {
|
||||
workspaceId: Uuid;
|
||||
taskId: Uuid;
|
||||
taskVersion: number;
|
||||
projectId: Uuid;
|
||||
projectActive: boolean;
|
||||
missionId: Uuid | null;
|
||||
missionActive: boolean;
|
||||
status: 'ready';
|
||||
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||
boardRank: string;
|
||||
dueAt: IsoTimestamp | null;
|
||||
notBeforeAt: IsoTimestamp | null;
|
||||
createdAt: IsoTimestamp;
|
||||
requiredRole: SpecialistRole;
|
||||
requiredCapabilities: readonly string[];
|
||||
blockingDependencies: readonly {
|
||||
taskId: Uuid;
|
||||
done: boolean;
|
||||
completionConditionSatisfied: boolean;
|
||||
}[];
|
||||
releaseApproval: {
|
||||
decisionId: Uuid;
|
||||
approved: boolean;
|
||||
policyRevision: string;
|
||||
} | null;
|
||||
activeLeaseId: Uuid | null;
|
||||
retry: RetryStateSnapshotV1;
|
||||
}
|
||||
|
||||
export interface AgentSessionSnapshotV1 {
|
||||
workspaceId: Uuid;
|
||||
agentId: Uuid;
|
||||
sessionId: Uuid;
|
||||
state: 'available' | 'busy';
|
||||
roles: readonly SpecialistRole[];
|
||||
capabilities: readonly string[];
|
||||
capacity: number;
|
||||
activeLeaseCount: number;
|
||||
heartbeatAt: IsoTimestamp;
|
||||
}
|
||||
|
||||
export interface EligibilityExplanationV1 {
|
||||
taskId: Uuid;
|
||||
eligible: boolean;
|
||||
readiness: ReadinessState;
|
||||
reasons: readonly {
|
||||
gate:
|
||||
| 'status'
|
||||
| 'project'
|
||||
| 'mission'
|
||||
| 'dependency'
|
||||
| 'schedule'
|
||||
| 'retry'
|
||||
| 'approval'
|
||||
| 'lease'
|
||||
| 'capability'
|
||||
| 'capacity'
|
||||
| 'health';
|
||||
satisfied: boolean;
|
||||
code: string;
|
||||
detail: string;
|
||||
}[];
|
||||
policyRevision: string;
|
||||
evaluatedAt: IsoTimestamp;
|
||||
}
|
||||
|
||||
export interface AssignmentProposalDecisionV1 {
|
||||
workspaceId: Uuid;
|
||||
taskId: Uuid;
|
||||
taskVersion: number;
|
||||
targetAgentId: Uuid;
|
||||
targetSessionId: Uuid;
|
||||
specialistRole: SpecialistRole;
|
||||
initialState: 'awaiting_approval' | 'policy_pre_authorized';
|
||||
policyRevision: string;
|
||||
explanation: EligibilityExplanationV1;
|
||||
expiresAt: IsoTimestamp;
|
||||
}
|
||||
|
||||
export interface AssignmentCycleSnapshotV1 {
|
||||
context: KanbanEvaluationContextV1;
|
||||
tasks: readonly TaskEligibilitySnapshotV1[];
|
||||
sessions: readonly AgentSessionSnapshotV1[];
|
||||
workspaceFairness: Readonly<Record<Uuid, number>>;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface AssignmentCycleDecisionV1 {
|
||||
evaluatedTaskCount: number;
|
||||
proposals: readonly AssignmentProposalDecisionV1[];
|
||||
explanations: readonly EligibilityExplanationV1[];
|
||||
}
|
||||
|
||||
export interface LeaseExpirySnapshotV1 {
|
||||
workspaceId: Uuid;
|
||||
taskId: Uuid;
|
||||
taskVersion: number;
|
||||
leaseId: Uuid;
|
||||
assignmentId: Uuid;
|
||||
sessionId: Uuid;
|
||||
fencingToken: FencingTokenV1;
|
||||
state: 'pending_ack' | 'active';
|
||||
acknowledgeBy: IsoTimestamp;
|
||||
expiresAt: IsoTimestamp;
|
||||
lastHeartbeatAt: IsoTimestamp | null;
|
||||
retry: RetryStateSnapshotV1;
|
||||
}
|
||||
|
||||
export interface LeaseExpiryDecisionV1 {
|
||||
leaseId: Uuid;
|
||||
action: 'retain' | 'release' | 'retry' | 'quarantine' | 'exhaust';
|
||||
reason: string;
|
||||
nextEligibleAt: IsoTimestamp | null;
|
||||
}
|
||||
|
||||
/** Pure package owned by KBN-200. It receives complete immutable snapshots. */
|
||||
export interface MechanicalCoordinatorDecisionEngineV1 {
|
||||
evaluateAssignmentCycle(snapshot: AssignmentCycleSnapshotV1): AssignmentCycleDecisionV1;
|
||||
explainEligibility(
|
||||
context: KanbanEvaluationContextV1,
|
||||
task: TaskEligibilitySnapshotV1,
|
||||
sessions: readonly AgentSessionSnapshotV1[],
|
||||
): EligibilityExplanationV1;
|
||||
decideLeaseExpiry(
|
||||
context: KanbanEvaluationContextV1,
|
||||
lease: LeaseExpirySnapshotV1,
|
||||
): LeaseExpiryDecisionV1;
|
||||
}
|
||||
|
||||
export interface PersistedAssignmentV1 {
|
||||
assignmentId: Uuid;
|
||||
workspaceId: Uuid;
|
||||
taskId: Uuid;
|
||||
taskVersion: number;
|
||||
targetAgentId: Uuid;
|
||||
targetSessionId: Uuid;
|
||||
specialistRole: SpecialistRole;
|
||||
state: AssignmentStateV1;
|
||||
policyRevision: string;
|
||||
proposedBy: { kind: 'user' | 'agent'; id: Uuid };
|
||||
reason: string;
|
||||
createdAt: IsoTimestamp;
|
||||
expiresAt: IsoTimestamp;
|
||||
}
|
||||
|
||||
export interface TaskLeaseV1 {
|
||||
leaseId: Uuid;
|
||||
workspaceId: Uuid;
|
||||
taskId: Uuid;
|
||||
taskVersion: number;
|
||||
assignmentId: Uuid;
|
||||
agentId: Uuid;
|
||||
sessionId: Uuid;
|
||||
state: 'pending_ack' | 'active';
|
||||
fencingToken: FencingTokenV1;
|
||||
attempt: number;
|
||||
acquiredAt: IsoTimestamp;
|
||||
acknowledgeBy: IsoTimestamp;
|
||||
lastHeartbeatAt: IsoTimestamp | null;
|
||||
expiresAt: IsoTimestamp;
|
||||
}
|
||||
|
||||
interface ServiceCommandBaseV1 {
|
||||
context: InternalKanbanMutationContextV1;
|
||||
taskId: Uuid;
|
||||
expectedTaskVersion: number;
|
||||
}
|
||||
|
||||
export interface AcquireApprovedLeaseCommandV1 extends ServiceCommandBaseV1 {
|
||||
assignmentId: Uuid;
|
||||
approvalDecisionId: Uuid;
|
||||
targetSessionId: Uuid;
|
||||
leaseTtlSeconds: number;
|
||||
}
|
||||
|
||||
export interface LeaseCommandV1 extends ServiceCommandBaseV1 {
|
||||
leaseId: Uuid;
|
||||
sessionId: Uuid;
|
||||
fencingToken: FencingTokenV1;
|
||||
}
|
||||
|
||||
export interface HeartbeatLeaseCommandV1 extends LeaseCommandV1 {
|
||||
extendSeconds: number;
|
||||
}
|
||||
|
||||
export interface CheckpointCommandV1 extends LeaseCommandV1 {
|
||||
sequence: number;
|
||||
resumableSummary: string;
|
||||
artifactIds: readonly Uuid[];
|
||||
contextUsagePercent: number;
|
||||
}
|
||||
|
||||
export interface SubmitForReviewCommandV1 extends LeaseCommandV1 {
|
||||
artifactIds: readonly Uuid[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ReleaseLeaseCommandV1 extends LeaseCommandV1 {
|
||||
reason:
|
||||
| 'worker_requested'
|
||||
| 'ack_timeout'
|
||||
| 'heartbeat_timeout'
|
||||
| 'task_submitted'
|
||||
| 'policy_revoked'
|
||||
| 'shutdown';
|
||||
}
|
||||
|
||||
export interface AssignmentCycleCommandV1 {
|
||||
context: InternalKanbanMutationContextV1;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ExpirySweepCommandV1 {
|
||||
context: InternalKanbanMutationContextV1;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface RecoverCoordinatorCommandV1 {
|
||||
context: InternalKanbanMutationContextV1;
|
||||
}
|
||||
|
||||
interface CoordinatorRejectionBaseV1 {
|
||||
kind: 'coordinator_rejection';
|
||||
retryable: false;
|
||||
requestOutcome: 'not_applied';
|
||||
correlationId: Uuid;
|
||||
idempotencyKey: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type CoordinatorPolicyRejectionV1 =
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'WORKSPACE_MISMATCH' })
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'TASK_NOT_ELIGIBLE';
|
||||
explanation: EligibilityExplanationV1;
|
||||
})
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_REQUIRED' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_STALE' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_STALE' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_TARGET_MISMATCH' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'POLICY_REVISION_MISMATCH' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'ARTIFACT_WORKSPACE_MISMATCH' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_ALREADY_ACTIVE' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_FOUND' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_ACTIVE' })
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'ACK_DEADLINE_EXPIRED';
|
||||
expiredAt: IsoTimestamp;
|
||||
})
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'FENCING_TOKEN_STALE';
|
||||
currentFencingToken: FencingTokenV1;
|
||||
})
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'SESSION_MISMATCH' })
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'HEARTBEAT_EXPIRED';
|
||||
expiredAt: IsoTimestamp;
|
||||
})
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'CHECKPOINT_SEQUENCE_CONFLICT';
|
||||
currentSequence: number;
|
||||
})
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'RETRY_EXHAUSTED' })
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'NON_IDEMPOTENT_RETRY_REQUIRES_ORCHESTRATOR';
|
||||
});
|
||||
|
||||
/** Explicit mapping to the Gateway mutation failure union; no arbitrary booleans. */
|
||||
export type CoordinatorFailureV1 =
|
||||
| DeliberateWriteDenialV1
|
||||
| VersionConflictV1
|
||||
| RetryableTransportErrorV1
|
||||
| CoordinatorPolicyRejectionV1;
|
||||
|
||||
export interface CoordinatorSuccessV1<T> {
|
||||
ok: true;
|
||||
value: T;
|
||||
correlationId: Uuid;
|
||||
}
|
||||
export interface CoordinatorFailureResultV1 {
|
||||
ok: false;
|
||||
failure: CoordinatorFailureV1;
|
||||
}
|
||||
export type CoordinatorResultV1<T> = CoordinatorSuccessV1<T> | CoordinatorFailureResultV1;
|
||||
|
||||
export interface ExpirySweepResultV1 {
|
||||
examined: number;
|
||||
released: readonly Uuid[];
|
||||
retryScheduled: readonly Uuid[];
|
||||
quarantined: readonly Uuid[];
|
||||
exhausted: readonly Uuid[];
|
||||
}
|
||||
|
||||
export interface RestartRecoveryResultV1 {
|
||||
activeLeaseIds: readonly Uuid[];
|
||||
expiredLeaseIds: readonly Uuid[];
|
||||
pendingAssignmentIds: readonly Uuid[];
|
||||
pendingOutboxEventIds: readonly Uuid[];
|
||||
}
|
||||
|
||||
/** Persistence/Gateway adapter owned by KBN-210. */
|
||||
export interface MechanicalCoordinatorServicePortV1 {
|
||||
/** Loads immutable snapshots, invokes pure engine, and persists proposals atomically. */
|
||||
runAssignmentCycle(
|
||||
command: AssignmentCycleCommandV1,
|
||||
): Promise<CoordinatorResultV1<{ assignments: readonly PersistedAssignmentV1[] }>>;
|
||||
|
||||
/** Query path loads by ID; public health observation cannot authorize mutation. */
|
||||
getEligibilityExplanation(
|
||||
context: KanbanEvaluationContextV1,
|
||||
taskId: Uuid,
|
||||
): Promise<CoordinatorResultV1<EligibilityExplanationV1>>;
|
||||
|
||||
/**
|
||||
* Accepts IDs only. Implementation reloads and locks assignment + approval +
|
||||
* task + target session in PostgreSQL, then verifies workspace, task version,
|
||||
* target agent/session, state, expiry, policy revision, and current approval.
|
||||
*/
|
||||
acquireApprovedLease(
|
||||
command: AcquireApprovedLeaseCommandV1,
|
||||
): Promise<CoordinatorResultV1<TaskLeaseV1>>;
|
||||
|
||||
acknowledgeLease(command: LeaseCommandV1): Promise<CoordinatorResultV1<TaskLeaseV1>>;
|
||||
heartbeatLease(command: HeartbeatLeaseCommandV1): Promise<CoordinatorResultV1<TaskLeaseV1>>;
|
||||
appendCheckpoint(
|
||||
command: CheckpointCommandV1,
|
||||
): Promise<CoordinatorResultV1<{ checkpointId: Uuid }>>;
|
||||
submitForReview(
|
||||
command: SubmitForReviewCommandV1,
|
||||
): Promise<CoordinatorResultV1<{ taskVersion: number; status: 'in_review' }>>;
|
||||
releaseLease(command: ReleaseLeaseCommandV1): Promise<CoordinatorResultV1<{ released: true }>>;
|
||||
expireAndRecover(
|
||||
command: ExpirySweepCommandV1,
|
||||
): Promise<CoordinatorResultV1<ExpirySweepResultV1>>;
|
||||
recoverFromPostgres(
|
||||
command: RecoverCoordinatorCommandV1,
|
||||
): Promise<CoordinatorResultV1<RestartRecoveryResultV1>>;
|
||||
}
|
||||
|
||||
/** Compile-time mapping guarantee: Coordinator Gateway failures are Kanban failures or exact policy rejections. */
|
||||
export function isKanbanMutationFailureV1(
|
||||
failure: CoordinatorFailureV1,
|
||||
): failure is KanbanMutationFailureV1 {
|
||||
return (
|
||||
failure.kind === 'deliberate_fail_closed_denial' ||
|
||||
failure.kind === 'retryable_transport_error' ||
|
||||
failure.kind === 'version_conflict'
|
||||
);
|
||||
}
|
||||
369
docs/native-kanban-sot/contracts/recovery-posture.v1.ts
Normal file
369
docs/native-kanban-sot/contracts/recovery-posture.v1.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Mosaic Native Kanban — frozen recovery-posture contract v1.
|
||||
* Recovery posture is configurable; SOT, write-health, Coordinator authority,
|
||||
* and gate semantics are not fields and cannot be overridden.
|
||||
*/
|
||||
|
||||
export const RECOVERY_POSTURE_CONTRACT_VERSION = '1.0.0' as const;
|
||||
export const recoveryTiers = ['lite', 'standard', 'high-assurance'] as const;
|
||||
export type RecoveryTier = (typeof recoveryTiers)[number];
|
||||
|
||||
export interface OffClusterStorageV1 {
|
||||
required: true;
|
||||
encrypted: true;
|
||||
separateFailureDomain: true;
|
||||
minimumCopies: number;
|
||||
storageClass: 'encrypted-object-storage' | 'encrypted-backup-target';
|
||||
}
|
||||
|
||||
export interface RecoveryPostureV1 {
|
||||
contractVersion: typeof RECOVERY_POSTURE_CONTRACT_VERSION;
|
||||
tier: RecoveryTier;
|
||||
targetRpoMinutes: number;
|
||||
targetRtoMinutes: number;
|
||||
baseBackupIntervalHours: number;
|
||||
/** null means WAL archival/PITR is disabled. */
|
||||
walArchiveIntervalMinutes: number | null;
|
||||
/** 0 means PITR is disabled. */
|
||||
pitrRetentionDays: number;
|
||||
restoreTestIntervalDays: number;
|
||||
breakGlassDrillIntervalDays: number;
|
||||
offClusterStorage: OffClusterStorageV1;
|
||||
}
|
||||
|
||||
export const recoveryPostureDefaults: Readonly<Record<RecoveryTier, RecoveryPostureV1>> = {
|
||||
lite: {
|
||||
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
||||
tier: 'lite',
|
||||
targetRpoMinutes: 24 * 60,
|
||||
targetRtoMinutes: 24 * 60,
|
||||
baseBackupIntervalHours: 24,
|
||||
walArchiveIntervalMinutes: null,
|
||||
pitrRetentionDays: 0,
|
||||
restoreTestIntervalDays: 90,
|
||||
breakGlassDrillIntervalDays: 365,
|
||||
offClusterStorage: {
|
||||
required: true,
|
||||
encrypted: true,
|
||||
separateFailureDomain: true,
|
||||
minimumCopies: 1,
|
||||
storageClass: 'encrypted-backup-target',
|
||||
},
|
||||
},
|
||||
standard: {
|
||||
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
||||
tier: 'standard',
|
||||
targetRpoMinutes: 60,
|
||||
targetRtoMinutes: 8 * 60,
|
||||
baseBackupIntervalHours: 24,
|
||||
walArchiveIntervalMinutes: 15,
|
||||
pitrRetentionDays: 14,
|
||||
restoreTestIntervalDays: 90,
|
||||
breakGlassDrillIntervalDays: 180,
|
||||
offClusterStorage: {
|
||||
required: true,
|
||||
encrypted: true,
|
||||
separateFailureDomain: true,
|
||||
minimumCopies: 1,
|
||||
storageClass: 'encrypted-object-storage',
|
||||
},
|
||||
},
|
||||
'high-assurance': {
|
||||
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
||||
tier: 'high-assurance',
|
||||
targetRpoMinutes: 15,
|
||||
targetRtoMinutes: 4 * 60,
|
||||
baseBackupIntervalHours: 24,
|
||||
walArchiveIntervalMinutes: 5,
|
||||
pitrRetentionDays: 35,
|
||||
restoreTestIntervalDays: 30,
|
||||
breakGlassDrillIntervalDays: 90,
|
||||
offClusterStorage: {
|
||||
required: true,
|
||||
encrypted: true,
|
||||
separateFailureDomain: true,
|
||||
minimumCopies: 1,
|
||||
storageClass: 'encrypted-object-storage',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Shape schema. Normative cross-field semantics are enforced by validateRecoveryPostureV1. */
|
||||
export const recoveryPostureJsonSchemaV1 = {
|
||||
$id: 'https://mosaicstack.dev/contracts/recovery-posture.v1.schema.json',
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
'contractVersion',
|
||||
'tier',
|
||||
'targetRpoMinutes',
|
||||
'targetRtoMinutes',
|
||||
'baseBackupIntervalHours',
|
||||
'walArchiveIntervalMinutes',
|
||||
'pitrRetentionDays',
|
||||
'restoreTestIntervalDays',
|
||||
'breakGlassDrillIntervalDays',
|
||||
'offClusterStorage',
|
||||
],
|
||||
properties: {
|
||||
contractVersion: { const: RECOVERY_POSTURE_CONTRACT_VERSION },
|
||||
tier: { enum: recoveryTiers },
|
||||
targetRpoMinutes: { type: 'integer', minimum: 1 },
|
||||
targetRtoMinutes: { type: 'integer', minimum: 1 },
|
||||
baseBackupIntervalHours: { type: 'integer', minimum: 1 },
|
||||
walArchiveIntervalMinutes: {
|
||||
anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }],
|
||||
},
|
||||
pitrRetentionDays: { type: 'integer', minimum: 0 },
|
||||
restoreTestIntervalDays: { type: 'integer', minimum: 1 },
|
||||
breakGlassDrillIntervalDays: { type: 'integer', minimum: 1 },
|
||||
offClusterStorage: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['required', 'encrypted', 'separateFailureDomain', 'minimumCopies', 'storageClass'],
|
||||
properties: {
|
||||
required: { const: true },
|
||||
encrypted: { const: true },
|
||||
separateFailureDomain: { const: true },
|
||||
minimumCopies: { type: 'integer', minimum: 1 },
|
||||
storageClass: {
|
||||
enum: ['encrypted-object-storage', 'encrypted-backup-target'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const recoveryValidationCodes = [
|
||||
'INVALID_SHAPE',
|
||||
'UNKNOWN_FIELD',
|
||||
'PITR_REQUIRES_WAL',
|
||||
'WAL_REQUIRES_PITR',
|
||||
'RPO_BETTER_THAN_MECHANISM',
|
||||
'OFF_CLUSTER_REQUIRED',
|
||||
'HIGH_ASSURANCE_WEAKENED',
|
||||
] as const;
|
||||
export type RecoveryValidationCode = (typeof recoveryValidationCodes)[number];
|
||||
|
||||
export interface RecoveryValidationIssueV1 {
|
||||
code: RecoveryValidationCode;
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
export type RecoveryValidationResultV1 =
|
||||
| { ok: true; value: RecoveryPostureV1 }
|
||||
| { ok: false; issues: RecoveryValidationIssueV1[] };
|
||||
|
||||
const topLevelFields = new Set([
|
||||
'contractVersion',
|
||||
'tier',
|
||||
'targetRpoMinutes',
|
||||
'targetRtoMinutes',
|
||||
'baseBackupIntervalHours',
|
||||
'walArchiveIntervalMinutes',
|
||||
'pitrRetentionDays',
|
||||
'restoreTestIntervalDays',
|
||||
'breakGlassDrillIntervalDays',
|
||||
'offClusterStorage',
|
||||
]);
|
||||
const storageFields = new Set([
|
||||
'required',
|
||||
'encrypted',
|
||||
'separateFailureDomain',
|
||||
'minimumCopies',
|
||||
'storageClass',
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && Number(value) > 0;
|
||||
}
|
||||
function isNonnegativeInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normative parser/refinement. Deployment code MUST call this function (or a
|
||||
* byte-for-byte behaviorally equivalent generated validator), not JSON Schema
|
||||
* shape validation alone.
|
||||
*/
|
||||
export function validateRecoveryPostureV1(input: unknown): RecoveryValidationResultV1 {
|
||||
const issues: RecoveryValidationIssueV1[] = [];
|
||||
if (!isRecord(input)) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: [{ code: 'INVALID_SHAPE', path: '$', message: 'posture must be an object' }],
|
||||
};
|
||||
}
|
||||
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!topLevelFields.has(key)) {
|
||||
issues.push({ code: 'UNKNOWN_FIELD', path: `$.${key}`, message: 'unknown field' });
|
||||
}
|
||||
}
|
||||
|
||||
const tier = input['tier'];
|
||||
const storage = input['offClusterStorage'];
|
||||
const integerFields = [
|
||||
'targetRpoMinutes',
|
||||
'targetRtoMinutes',
|
||||
'baseBackupIntervalHours',
|
||||
'restoreTestIntervalDays',
|
||||
'breakGlassDrillIntervalDays',
|
||||
] as const;
|
||||
|
||||
if (input['contractVersion'] !== RECOVERY_POSTURE_CONTRACT_VERSION) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.contractVersion',
|
||||
message: `must equal ${RECOVERY_POSTURE_CONTRACT_VERSION}`,
|
||||
});
|
||||
}
|
||||
if (!recoveryTiers.includes(tier as RecoveryTier)) {
|
||||
issues.push({ code: 'INVALID_SHAPE', path: '$.tier', message: 'unknown recovery tier' });
|
||||
}
|
||||
for (const field of integerFields) {
|
||||
if (!isPositiveInteger(input[field])) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: `$.${field}`,
|
||||
message: 'must be a positive integer',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!isNonnegativeInteger(input['pitrRetentionDays'])) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.pitrRetentionDays',
|
||||
message: 'must be a nonnegative integer',
|
||||
});
|
||||
}
|
||||
if (
|
||||
input['walArchiveIntervalMinutes'] !== null &&
|
||||
!isPositiveInteger(input['walArchiveIntervalMinutes'])
|
||||
) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.walArchiveIntervalMinutes',
|
||||
message: 'must be null or a positive integer',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isRecord(storage)) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.offClusterStorage',
|
||||
message: 'must be an object',
|
||||
});
|
||||
} else {
|
||||
for (const key of Object.keys(storage)) {
|
||||
if (!storageFields.has(key)) {
|
||||
issues.push({
|
||||
code: 'UNKNOWN_FIELD',
|
||||
path: `$.offClusterStorage.${key}`,
|
||||
message: 'unknown field',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
storage['required'] !== true ||
|
||||
storage['encrypted'] !== true ||
|
||||
storage['separateFailureDomain'] !== true
|
||||
) {
|
||||
issues.push({
|
||||
code: 'OFF_CLUSTER_REQUIRED',
|
||||
path: '$.offClusterStorage',
|
||||
message: 'storage must be required, encrypted, and in a separate failure domain',
|
||||
});
|
||||
}
|
||||
if (!isPositiveInteger(storage['minimumCopies'])) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.offClusterStorage.minimumCopies',
|
||||
message: 'must be a positive integer',
|
||||
});
|
||||
}
|
||||
if (
|
||||
storage['storageClass'] !== 'encrypted-object-storage' &&
|
||||
storage['storageClass'] !== 'encrypted-backup-target'
|
||||
) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.offClusterStorage.storageClass',
|
||||
message: 'unsupported storage class',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const wal = input['walArchiveIntervalMinutes'];
|
||||
const pitr = input['pitrRetentionDays'];
|
||||
if (pitr !== 0 && wal === null) {
|
||||
issues.push({
|
||||
code: 'PITR_REQUIRES_WAL',
|
||||
path: '$.pitrRetentionDays',
|
||||
message: 'PITR retention requires WAL archival',
|
||||
});
|
||||
}
|
||||
if (wal !== null && pitr === 0) {
|
||||
issues.push({
|
||||
code: 'WAL_REQUIRES_PITR',
|
||||
path: '$.walArchiveIntervalMinutes',
|
||||
message: 'WAL archival requires positive PITR retention',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isPositiveInteger(input['targetRpoMinutes']) &&
|
||||
isPositiveInteger(input['baseBackupIntervalHours']) &&
|
||||
(wal === null || isPositiveInteger(wal))
|
||||
) {
|
||||
const mechanismMinutes = wal === null ? input['baseBackupIntervalHours'] * 60 : wal;
|
||||
if (mechanismMinutes > input['targetRpoMinutes']) {
|
||||
issues.push({
|
||||
code: 'RPO_BETTER_THAN_MECHANISM',
|
||||
path: '$.targetRpoMinutes',
|
||||
message: `configured mechanism can only support ${mechanismMinutes} minutes`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tier === 'high-assurance') {
|
||||
const weakened =
|
||||
!isPositiveInteger(input['targetRpoMinutes']) ||
|
||||
input['targetRpoMinutes'] > 15 ||
|
||||
!isPositiveInteger(input['targetRtoMinutes']) ||
|
||||
input['targetRtoMinutes'] > 4 * 60 ||
|
||||
!isPositiveInteger(input['baseBackupIntervalHours']) ||
|
||||
input['baseBackupIntervalHours'] > 24 ||
|
||||
!isPositiveInteger(wal) ||
|
||||
wal > 5 ||
|
||||
!isNonnegativeInteger(pitr) ||
|
||||
pitr < 35 ||
|
||||
!isPositiveInteger(input['restoreTestIntervalDays']) ||
|
||||
input['restoreTestIntervalDays'] > 30 ||
|
||||
!isPositiveInteger(input['breakGlassDrillIntervalDays']) ||
|
||||
input['breakGlassDrillIntervalDays'] > 90;
|
||||
if (weakened) {
|
||||
issues.push({
|
||||
code: 'HIGH_ASSURANCE_WEAKENED',
|
||||
path: '$',
|
||||
message: 'high-assurance posture may be strengthened but not weakened',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) return { ok: false, issues };
|
||||
return { ok: true, value: input as unknown as RecoveryPostureV1 };
|
||||
}
|
||||
|
||||
export interface RecoveryPostureOverrideAuditV1 {
|
||||
actorId: string;
|
||||
reason: string;
|
||||
effectiveAt: string;
|
||||
policyRevision: string;
|
||||
previous: RecoveryPostureV1;
|
||||
next: RecoveryPostureV1;
|
||||
}
|
||||
16
docs/native-kanban-sot/tsconfig.json
Normal file
16
docs/native-kanban-sot/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"incremental": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"drizzle-orm": ["../../packages/db/node_modules/drizzle-orm/index.d.ts"],
|
||||
"drizzle-orm/pg-core": ["../../packages/db/node_modules/drizzle-orm/pg-core/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["contracts/*.ts"]
|
||||
}
|
||||
@@ -124,7 +124,7 @@ paths:
|
||||
{
|
||||
summary: Submit Mos handoff,
|
||||
parameters: [{ $ref: '#/components/parameters/correlation' }],
|
||||
requestBody: { $ref: '#/components/requestBodies/MosHandoff' },
|
||||
requestBody: { $ref: '#/components/requestBodies/Handoff' },
|
||||
responses: { '200': { description: Receipt } },
|
||||
},
|
||||
}
|
||||
@@ -261,7 +261,7 @@ components:
|
||||
},
|
||||
},
|
||||
}
|
||||
MosHandoff:
|
||||
Handoff:
|
||||
{
|
||||
required: true,
|
||||
content:
|
||||
|
||||
59
docs/reports/native-kanban-sot/canon-final-rereview-go.md
Normal file
59
docs/reports/native-kanban-sot/canon-final-rereview-go.md
Normal file
@@ -0,0 +1,59 @@
|
||||
VERDICT: GO
|
||||
|
||||
# Native Kanban/SOT canon independent re-review 2
|
||||
|
||||
Independent read-only re-review of the complete updated staged canon. Prior proposal-audit blocker is closed; no KCR-001–016 regression or new blocker found.
|
||||
|
||||
## Prior blocker closure
|
||||
|
||||
- `contracts/kanban-schema.v1.ts:836-837` declares the required unique `task_events(workspace_id,id)` key before proposal declaration.
|
||||
- `contracts/kanban-schema.v1.ts:885-894` adds both composite proposal audit FKs—submission and accepted-command event—to that exact workspace-aware key with `RESTRICT`.
|
||||
- Declaration/migration order is executable and explicit in `SHARED-CONTRACT.md:79-91`: events/key first, proposal table second, both FKs third/fourth, then command enablement. This avoids forward-reference/circular-DDL ambiguity.
|
||||
- Submission/acceptance semantics are frozen in `SHARED-CONTRACT.md:87-91`: preallocate proposal ID; create exact `change_proposal.submitted` event and proposal in one transaction; on acceptance lock proposal/target, execute the normal command, and bind only a same-workspace/target event with submission causation and `payload.changeProposalId` equal to the locked proposal.
|
||||
- Required missing, foreign-workspace, unrelated-proposal, unrelated-target, and unrelated-command negatives are explicit in `REQUIREMENTS.md` REQ-SOT-004 and `SHARED-CONTRACT.md:121`; KBN-100/110/140 own migration, service, and integration evidence.
|
||||
|
||||
## KCR closure matrix
|
||||
|
||||
| KCR | Status |
|
||||
| ------------------------------------------------------ | ------ |
|
||||
| 001 health/proof | CLOSED |
|
||||
| 002 error discrimination | CLOSED |
|
||||
| 003 approval/assignment binding | CLOSED |
|
||||
| 004 monotonic fencing/composites | CLOSED |
|
||||
| 005 tenant-safe relations | CLOSED |
|
||||
| 006 outage proposal persistence/commands/audit binding | CLOSED |
|
||||
| 007 dependency/API freeze sequencing | CLOSED |
|
||||
| 008 concrete N-1 map | CLOSED |
|
||||
| 009 dependency uniqueness | CLOSED |
|
||||
| 010 project congruence | CLOSED |
|
||||
| 011 immutable audit retention | CLOSED |
|
||||
| 012 retry/quarantine/vocabulary | CLOSED |
|
||||
| 013 archive/tags target semantics | CLOSED |
|
||||
| 014 recovery validator/owner slice | CLOSED |
|
||||
| 015 pure Coordinator split | CLOSED |
|
||||
| 016 health code/state pairing | CLOSED |
|
||||
|
||||
Fixed invariants remain consistent: PostgreSQL is sole writable SOT; writes require transaction-local proof and fail closed; exports never import sources; notes are attributable proposals only; Coordinator has no scope/gate/certify/merge authority; Certifier has no merge authority.
|
||||
|
||||
## Reproducible validation evidence
|
||||
|
||||
Executed read-only with current-stack config/toolchain `/src/mosaic-mono-v1`:
|
||||
|
||||
```text
|
||||
./node_modules/.bin/prettier --config /src/mosaic-mono-v1/.prettierrc --check <all 9 publication artifacts>
|
||||
PASS: All matched files use Prettier code style.
|
||||
|
||||
strict TypeScript --noEmit --strict --skipLibCheck --target ES2022 --module NodeNext --moduleResolution NodeNext <four contract copies with current Drizzle node_modules resolution>
|
||||
PASS
|
||||
|
||||
cascade/TODO/TBD/stale-hold grep plus composite-FK/semantic-marker invariant checks
|
||||
PASS
|
||||
```
|
||||
|
||||
The TypeScript check used a disposable copy under `/home/hermes/agent-work` solely to provide external-file NodeNext dependency resolution; the reviewed staging artifacts were not modified.
|
||||
|
||||
## Residual findings
|
||||
|
||||
None blocking. Implementation must execute the frozen KBN-100/KBN-110/KBN-140 proposal-event-chain tests and SecReview evidence before feature release, as already required by the canon.
|
||||
|
||||
No artifact source repository, branch, PR, or provider state was modified.
|
||||
357
docs/reports/native-kanban-sot/canon-initial-review-no-go.md
Normal file
357
docs/reports/native-kanban-sot/canon-initial-review-no-go.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Independent Review — Native Kanban/SOT Canon
|
||||
|
||||
**Reviewer:** `enhance-sol` (independent of author `planner-sol`)
|
||||
**Date:** 2026-07-13
|
||||
**Review mode:** design/contract only; read-only against the staged canon
|
||||
**Source plan:** `/home/hermes/agent-work/planning/mosaic-native-kanban-sot-plan.md` (`sha256:96ea4fb91436ec9a53f371d27276e27f62ecf817662599ff9152df0db55296e5`)
|
||||
**Canon reviewed:** every listed artifact under `/home/hermes/agent-work/planning/kanban-canon/`, including the four TypeScript contracts; the author scratchpad was also read as validation context.
|
||||
|
||||
## Executive verdict
|
||||
|
||||
# NO-GO
|
||||
|
||||
The canon is not freeze-ready. I found **8 BLOCKERs**, **7 MAJORs**, and **1 MINOR**. The prose preserves the ratified authority model well, but the frozen types/schema leave concrete fail-closed, approval, fencing, tenant, outage-proposal, migration, and parallelization gaps. Those gaps would force implementation lanes either to invent contract semantics or to ship paths that violate fixed invariants.
|
||||
|
||||
### Blocking findings
|
||||
|
||||
1. Health/write authorization can be represented as contradictory, stale, or caller-asserted state.
|
||||
2. Coordinator failures collapse authoritative denial, unknown transport outcome, and version conflict into one permissive shape.
|
||||
3. Assignment proposals and approval proofs have no authoritative relational binding; lease acquisition accepts a forgeable proof DTO.
|
||||
4. Fencing uniqueness is present, but monotonic fencing and same-task lease/checkpoint binding are not.
|
||||
5. Workspace-safe accountable-owner, assignment-principal, and evidence/artifact relationships are not frozen.
|
||||
6. Attributable post-recovery proposals have neither a canonical table nor command contract.
|
||||
7. The slice graph starts schema/UI work before prerequisite threat and exact API/DTO freezes and contradicts coder4 lane order.
|
||||
8. P0 claims a migration map while publishing only generic rules; the concrete N-1 transition from current `origin/main` is absent.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### KCR-001 — BLOCKER — “Healthy” is not a proof and can be contradictory or stale
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/health-state.v1.ts:21-31` — `KanbanHealthResponseV1` permits every combination of `state`, `readHealthProven`, and `writeHealthProven`.
|
||||
- `contracts/mechanical-coordinator.v1.ts:40-49` — `CoordinatorContextV1` accepts a caller-supplied `healthState` enum only.
|
||||
- `contracts/mechanical-coordinator.v1.ts:255-293` — every Coordinator operation, including mutating operations, accepts that context.
|
||||
- `SHARED-CONTRACT.md:171-184` — mutations are allowed only after live PostgreSQL read/write probes.
|
||||
|
||||
**Violation**
|
||||
|
||||
Fixed invariant 2 / `REQ-SOT-002`: mutations must fail closed unless write health is positively proven. The current type permits `{ state: 'healthy', writeHealthProven: false }`, and the Coordinator mutation boundary can be invoked with a stale or fabricated `{ healthState: 'healthy' }`. A Valkey/client-derived enum could therefore be mistaken for write authorization.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
1. Make `KanbanHealthResponseV1` a discriminated union with only these legal combinations: `healthy => read=true/write=true`, `read-only-degraded => read=true/write=false`, and `write-unavailable => read=false/write=false`.
|
||||
2. Do not accept write authority from a public DTO. Require Gateway/domain code to obtain and revalidate a fresh internal PostgreSQL write-health proof at mutation time (including `checkedAt`, bounded validity/policy revision, and transaction-local enforcement).
|
||||
3. Split pure evaluation context from mutation context; mutation methods must accept only an unforgeable/internal healthy context or perform the probe themselves.
|
||||
4. Add negative contract tests for contradictory state, expired proof, Valkey-only liveness, and caller-forged `healthy`.
|
||||
|
||||
### KCR-002 — BLOCKER — Coordinator error shape can conflate denial, unknown outcome, and conflict
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/mechanical-coordinator.v1.ts:184-216` — one `CoordinatorFailureV1` allows every code to pair with arbitrary `retryable` and either `requestOutcome` value.
|
||||
- `contracts/health-state.v1.ts:51-106` — the Gateway health contract correctly distinguishes deliberate denial, transport uncertainty, and version conflict.
|
||||
- `SHARED-CONTRACT.md:177-216` — frozen client semantics require those cases not to be conflated.
|
||||
|
||||
**Violation**
|
||||
|
||||
Charter E and `REQ-SOT-002`. The current Coordinator result can legally encode `WRITE_HEALTH_UNPROVEN` as `retryable: true, requestOutcome: 'unknown'`, or `VERSION_CONFLICT` as retryable. That permits blind retry or a false “unknown” outcome after an authoritative fail-closed denial.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Replace `CoordinatorFailureV1` with a discriminated union keyed by code/kind:
|
||||
|
||||
- deliberate health denial: `not_applied`, `retryable:false`;
|
||||
- version conflict: `not_applied`, `retryable:false`, current version;
|
||||
- stale fence/session/eligibility/approval failures: exact non-retry semantics;
|
||||
- transport failure: a separate `retryable_transport_error`, `unknown`, same idempotency key.
|
||||
|
||||
Reuse or map explicitly to `KanbanMutationFailureV1`, and add exhaustive client tests proving 503 authoritative bodies, 502/504/timeouts, and 409 cannot cross-map.
|
||||
|
||||
### KCR-003 — BLOCKER — Approval proof is forgeable and is not linked to the persisted proposal
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/mechanical-coordinator.v1.ts:107-137` — proposal and approval DTOs.
|
||||
- `contracts/mechanical-coordinator.v1.ts:265-270` — `acquireApprovedLease` accepts the entire `ApprovalProofV1` by value.
|
||||
- `contracts/kanban-schema.v1.ts:650-688` — `task_assignments` has no proposal expiry, task version, session binding, or proposal/approval FK.
|
||||
- `contracts/kanban-schema.v1.ts:823-863` — `approval_decisions` can target only a task or mission and has no proposal/assignment relation.
|
||||
- `SHARED-CONTRACT.md:128-137` — lease acquisition requires authoritative approval under the exact policy revision.
|
||||
|
||||
**Violation**
|
||||
|
||||
Fixed invariant 5 and `REQ-COORD-002/003`. A caller can construct an `ApprovalProofV1`; the schema cannot prove that it belongs to the proposal, workspace, task version, agent/session, unexpired policy revision, or still-current approval. The DTO state vocabulary (`awaiting_approval | policy_pre_authorized`) also does not map directly to the persisted assignment states (`proposed | approved | ...`).
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Persist one authoritative proposal/assignment identity with task version, target agent/session, expiry, state, and policy revision. Add a workspace-aware approval relation to that identity. Change lease acquisition to accept IDs, then reload and lock proposal + approval + task inside PostgreSQL and verify workspace, current version, target session, state, expiry, and policy revision before creating the lease. Freeze one state vocabulary across schema and DTOs.
|
||||
|
||||
### KCR-004 — BLOCKER — Fencing is unique but not monotonically increasing; relational binding is incomplete
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/kanban-schema.v1.ts:694-743` — `task_leases` has positive/unique fencing tokens but no monotonic per-task counter.
|
||||
- `contracts/kanban-schema.v1.ts:748-784` — checkpoints independently carry task, lease, and fencing token.
|
||||
- `contracts/kanban-schema.v1.ts:905-910` — token equality is deferred to prose; same-task lease binding is not stated.
|
||||
- `contracts/mechanical-coordinator.v1.ts:140-175` — worker commands depend on fencing safety.
|
||||
|
||||
**Violation**
|
||||
|
||||
Fixed invariant 12 / `REQ-COORD-003`. Uniqueness permits token 10 followed by token 9. A lease can reference assignment A while naming task B in the same workspace, and a checkpoint can reference lease A while naming task B. `bigint(..., { mode: 'number' })` also eventually loses integer precision in JavaScript.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Add a durable per-task fencing counter (or equivalent PostgreSQL sequence row) incremented atomically under task lock and use the returned value for every new lease. Add workspace-aware composite constraints tying lease to its exact task+assignment and checkpoint to exact task+lease+fence. Use bigint-safe representation (`bigint`/serialized decimal), and test monotonicity, concurrent claims, stale lower tokens, and mismatched same-workspace IDs.
|
||||
|
||||
### KCR-005 — BLOCKER — Hard tenant boundary is not frozen for several polymorphic relationships
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/kanban-schema.v1.ts:315-318` and `475-478` — project/task accountable owners are unvalidated `(kind, text id)` pairs.
|
||||
- `contracts/kanban-schema.v1.ts:659-663` — assignment principals are unvalidated `(kind, text id)` pairs.
|
||||
- `contracts/kanban-schema.v1.ts:758` and `841` — checkpoint/evidence artifact relationships are JSON arrays without workspace-aware FKs.
|
||||
- `SHARED-CONTRACT.md:89-96` — only selected polymorphic checks are delegated to domain transactions; owner/principal/evidence checks are not included.
|
||||
- `REQUIREMENTS.md:101-108` — every relationship must reject cross-workspace IDs.
|
||||
|
||||
**Violation**
|
||||
|
||||
Fixed invariant 7 / `REQ-TEN-001` and `REQ-ID-001`. The frozen schema can name a team or agent from another workspace as owner/assignee, and can embed foreign-workspace artifact IDs in checkpoint or approval evidence arrays. A global user ID is also insufficient without active workspace membership validation.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Use workspace-aware owner/assignment join tables or separate nullable user/team/agent columns with exactly-one checks and composite FKs where possible. Model checkpoint/evidence artifact links as workspace-scoped join rows, or freeze explicit transaction checks for every ID. Require active workspace membership for user principals and workspace-agent/session consistency for agent principals. Add DB/repository/API/Coordinator cross-workspace negative tests without existence oracles.
|
||||
|
||||
### KCR-006 — BLOCKER — Post-recovery outage proposals have no canonical persistence or command surface
|
||||
|
||||
**Location**
|
||||
|
||||
- `REQUIREMENTS.md:93-99` — proposal submission and authorized accept/reject are required.
|
||||
- `SHARED-CONTRACT.md:26-29` — outage notes may return only as authenticated proposals.
|
||||
- `SHARED-CONTRACT.md:243-267` — the thin command/query contract contains no proposal submit/get/accept/reject operations.
|
||||
- `contracts/kanban-schema.v1.ts:1-916` — no proposal table captures proposed command, target/version, attribution, lifecycle, or decision.
|
||||
- `TASKS.md:99-108` — KBN-110 does not own an outage-proposal command path.
|
||||
|
||||
**Violation**
|
||||
|
||||
Fixed invariant 4 / `REQ-SOT-004`. An implementation lane would have to invent storage or misuse artifacts/approval gates. Either path risks silently applying an outage note or creating shadow state.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Add a workspace-scoped `change_proposals`/`outage_proposals` contract with authenticated proposer, source note digest, target aggregate, expected version, proposed typed command/payload, pending/accepted/rejected state, decision actor/reason/time, idempotency key, and audit linkage. Add explicit submit/query/accept/reject Gateway commands. Acceptance must execute the normal command in a healthy transaction; a proposal itself can never claim, order, satisfy a gate, or mutate the target.
|
||||
|
||||
### KCR-007 — BLOCKER — Parallel slice ordering is not freeze-safe and contains a direct lane-order contradiction
|
||||
|
||||
**Location**
|
||||
|
||||
- `TASKS.md:43-60` — dependency graph makes KBN-010 and KBN-100 siblings.
|
||||
- `TASKS.md:88-97` — KBN-100 nevertheless depends on KBN-010 threat findings that alter constraints.
|
||||
- `SHARED-CONTRACT.md:243` and `INDEX.md:44-50` — exact route names/DTO placement remain unresolved.
|
||||
- `TASKS.md:110-130` — KBN-120/130 depend on a frozen endpoint/DTO contract, while mocks may begin before KBN-110 lands.
|
||||
- `TASKS.md:145-153` — KBN-200 says lane-serial after KBN-120.
|
||||
- `TASKS.md:248-254` — wave table runs KBN-200 before KBN-120.
|
||||
|
||||
**Violation**
|
||||
|
||||
Charter C and the mandatory freeze-before-parallelize gate. Schema can begin before tenant/threat findings are complete; web/CLI consumers have only semantic operations, not exact DTO/endpoint contracts; coder4 has two opposite legal orders. This does not create same-file edits immediately, but it guarantees contract invention or rework across active lanes.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
1. Make KBN-010 (or an explicit constraint-impact gate from it) a completed prerequisite of KBN-100.
|
||||
2. Add a small serialized KBN-105 endpoint/DTO/endpoint-registry freeze, with exact request/response/error DTOs, before KBN-120 and KBN-130 implementation.
|
||||
3. Choose one coder4 lane order and use it consistently in slice text, graph, and wave table.
|
||||
4. Name the exact MCP-owned files or assign their Gateway changes to coder3 before coder4 starts.
|
||||
|
||||
### KCR-008 — BLOCKER — Claimed P0 migration map is absent; concrete N-1 hazards remain unresolved
|
||||
|
||||
**Location**
|
||||
|
||||
- `MISSION-MANIFEST.md:153-157` — P0 says to publish a migration map and states the build hold is lifted at line 3.
|
||||
- `SHARED-CONTRACT.md:101-121` — only generic expand/backfill/contract rules are supplied.
|
||||
- `contracts/kanban-schema.v1.ts:1-916` — target-state declarations reuse live table names and make target fields required.
|
||||
- Current foundation evidence: `origin/main:packages/db/src/schema.ts:120-301` has no workspace keys, nullable project/mission links, legacy text status vocabularies, `tasks.tags`, `tasks.assignee`, `tasks.due_date`, mission JSON milestones/config, `mission_tasks.status`, and legacy agent fields.
|
||||
|
||||
**Violation**
|
||||
|
||||
Charter D / `REQ-MIG-001` and the P0 exit claim. The generic rule is correct, but coder2 lacks the required field-by-field transition map. A direct Drizzle reconciliation could attempt type narrowing/status conversion, add required workspace/project/owner columns too early, or drop legacy columns before N-1 readers and writers are retired.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Publish a concrete current-main delta map before lifting the hold. For each existing table/column, specify expand, backfill, compatibility read/write, switch, and contract release. At minimum cover:
|
||||
|
||||
- nullable-first `workspace_id`, required project/owner fields, and workspace backfill;
|
||||
- legacy task/project/mission status aliases or shadow columns before v1 emission;
|
||||
- `mission_tasks.status` read retirement and write-source prohibition;
|
||||
- mapping/retention for tags, assignee, due date, mission description/config/milestones, and agent fields;
|
||||
- current milestone circular FK ordering;
|
||||
- empty, production-shape, partial-resume, and rollback/downgrade tests already named in §4.
|
||||
|
||||
Explicitly require legacy columns to remain in the unified Drizzle declaration during the expand/N-1 window.
|
||||
|
||||
### KCR-009 — MAJOR — Dependency uniqueness permits parallel duplicate edges
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/kanban-schema.v1.ts:561-567` — unique key includes `dependencyType`.
|
||||
- `SHARED-CONTRACT.md:47-49` — calls for a unique directed edge.
|
||||
- `REQUIREMENTS.md:142-149` — duplicate edge attempts must fail.
|
||||
|
||||
**Violation**
|
||||
|
||||
`REQ-DEP-001`. The same predecessor/successor pair can be inserted three times, once per dependency type. That is not a unique directed edge and complicates readiness semantics.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Make `(workspace_id, predecessor_task_id, successor_task_id)` unique independent of type, or explicitly redefine the requirement as one edge per type and freeze deterministic multi-edge completion semantics. The source plan says unique directed edge, so the former is the minimal faithful fix.
|
||||
|
||||
### KCR-010 — MAJOR — Same-workspace planning relationships can contradict the project hierarchy
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/kanban-schema.v1.ts:325` — `projects.currentMilestoneId` has no FK in the declaration.
|
||||
- `contracts/kanban-schema.v1.ts:427-448` — mission/milestone association checks workspace but not common project.
|
||||
- `contracts/kanban-schema.v1.ts:490-516` — a task’s project, mission, milestone, and parent only need share a workspace, not a project.
|
||||
- `contracts/kanban-schema.v1.ts:905-908` — only current milestone is mentioned as a deferred invariant.
|
||||
|
||||
**Violation**
|
||||
|
||||
`REQ-PLAN-001` and schema correctness. A task in project A can point to a mission/milestone/parent task from project B in the same workspace. A mission can associate a milestone from another project despite having one required project.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Add project-congruent composite keys/FKs (or freeze mandatory transaction checks) for task→mission, task→milestone, task→parent, mission→milestone, and project→current milestone. Add same-workspace/same-project negative tests.
|
||||
|
||||
### KCR-011 — MAJOR — Immutable/append-only records can be erased by parent cascades
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/kanban-schema.v1.ts:798-818` — `task_events` is described as append-only but remains under a workspace cascade.
|
||||
- `contracts/kanban-schema.v1.ts:911` — only application-role UPDATE/DELETE privilege removal is stated.
|
||||
- Numerous canonical relationships use `onDelete('cascade')`, including workspace roots and artifact/checkpoint/event owners.
|
||||
- `REQUIREMENTS.md:41-43` and `154-170` — audit must be append-only, attributable, and reconstructable.
|
||||
|
||||
**Violation**
|
||||
|
||||
`REQ-AUD-001`. Revoking direct DELETE on `task_events` does not prevent a parent delete from cascading into the audit log. Checkpoints and immutable artifacts also lack explicit append-only privilege/retention semantics.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Use lifecycle/archive states and `RESTRICT` for canonical parent deletion during normal operation. Freeze a separate, audited retention/break-glass purge procedure. Apply INSERT/SELECT-only or equivalent immutability controls to task events, checkpoints, and immutable artifacts, and test that parent deletion cannot silently erase them.
|
||||
|
||||
### KCR-012 — MAJOR — Coordinator persistence lacks durable quarantine/retry state and DTO/schema alignment
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/mechanical-coordinator.v1.ts:239-244` — expiry returns `quarantined` IDs.
|
||||
- `contracts/kanban-schema.v1.ts:457-490` — task has only untyped `retryPolicy` metadata and no quarantine/execution disposition.
|
||||
- `contracts/mechanical-coordinator.v1.ts:173` — `evidenceIds` has no corresponding evidence table/type; schema has artifacts.
|
||||
- `contracts/kanban-schema.v1.ts:479`, `663`, and agent role JSON — specialist roles are free text despite the frozen role vocabulary in `mechanical-coordinator.v1.ts:19-29`.
|
||||
|
||||
**Violation**
|
||||
|
||||
`REQ-COORD-004` and internal consistency. PostgreSQL cannot deterministically reconstruct why/when a task was quarantined, its bounded retry state, or which typed evidence was submitted. Free-text roles allow the schema and engine to disagree.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Freeze a durable execution/retry/quarantine record (attempt count, next eligibility, terminal reason, actor/policy, timestamps, version) or typed task columns with events. Align `evidenceIds` to artifact IDs or add a real evidence entity. Use one specialist-role enum/check across tasks, assignments, agents/sessions, DTOs, and Coordinator.
|
||||
|
||||
### KCR-013 — MAJOR — Thin MVP promises task archive and tag filtering without target-state semantics
|
||||
|
||||
**Location**
|
||||
|
||||
- `REQUIREMENTS.md:182-193` — users must archive tasks and filter by tags.
|
||||
- `SHARED-CONTRACT.md:252-267` — mutations include cancel but not archive task.
|
||||
- `contracts/kanban-schema.v1.ts:457-490` — no task archive field and no typed tags field/table.
|
||||
- Current `origin/main` already has `tasks.tags`, making omission from the target declaration a migration-loss hazard.
|
||||
|
||||
**Violation**
|
||||
|
||||
`REQ-UI-001/002` and internal acceptance consistency. “Archive” cannot be implemented without inventing whether it means cancelled, hidden, or soft-deleted; tag filtering has no frozen storage/query contract.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Either remove task archive/tag acceptance from P1, or add explicit non-lifecycle archival semantics (`archived_at/by/reason`) and a workspace-safe tags model/query contract. Preserve/migrate the current tags column until the selected model is live.
|
||||
|
||||
### KCR-014 — MAJOR — Recovery contract states critical rules only in comments and has no owning implementation slice
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/recovery-posture.v1.ts:97-147` — exported JSON Schema validates only local field shapes.
|
||||
- `contracts/recovery-posture.v1.ts:150-156` — PITR/WAL, effective RPO, off-cluster, high-assurance minima, and audit rules are comments only.
|
||||
- `REQUIREMENTS.md:270-277` — parser rejection of impossible combinations is acceptance-critical.
|
||||
- `TASKS.md:75-244` — no bounded slice owns recovery config parsing, override audit, backup/WAL setup, or restore/break-glass evidence.
|
||||
|
||||
**Violation**
|
||||
|
||||
`REQ-REC-001`. A consumer using the advertised JSON Schema can accept weakened high-assurance values, PITR without WAL, or an impossible RPO. The task plan has no lane accountable for closing that acceptance criterion.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Export a normative `validateRecoveryPostureV1`/schema refinement with machine-testable cross-field checks and add a bounded Infra/recovery slice (serialized if it touches shared config) owning config parsing, override audit, mechanism verification, restore test, and break-glass evidence. Recovery config must continue to expose no authority/gate knobs.
|
||||
|
||||
### KCR-015 — MAJOR — Pure Coordinator slice cannot implement two frozen methods without persistence access
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/mechanical-coordinator.v1.ts:259-263` — `explainEligibility` receives only `taskId`, not a structured snapshot.
|
||||
- `contracts/mechanical-coordinator.v1.ts:289-293` — `recoverFromPostgres` explicitly reads PostgreSQL.
|
||||
- `TASKS.md:145-153` — KBN-200 is a pure engine with no SQL, Drizzle, Gateway, or Valkey.
|
||||
- `TASKS.md:157-164` — persistence belongs to coder3/KBN-210.
|
||||
|
||||
**Violation**
|
||||
|
||||
Charter C and internal consistency. coder4 cannot implement the frozen port in a pure package without crossing coder3’s persistence boundary. If coder3 implements the port instead, KBN-200’s acceptance and ownership are misassigned.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Split the contract into a pure decision engine that receives complete immutable snapshots and a persistence/orchestration service port implemented by KBN-210. Move `recoverFromPostgres` and ID-based loading to the adapter/service; make pure explanation accept a snapshot.
|
||||
|
||||
### KCR-016 — MINOR — Health denial code/state pairs are not correlated by type
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/health-state.v1.ts:35-61` — either denial code can pair with either degraded state.
|
||||
- `SHARED-CONTRACT.md:188-190` — prose defines `KANBAN_WRITE_UNAVAILABLE` specifically for `write-unavailable`.
|
||||
|
||||
**Violation**
|
||||
|
||||
Health contract precision. A client can receive a semantically inconsistent authoritative body even after KCR-001’s broader state fix.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Make deliberate denial a two-variant union with exact code/state pairing.
|
||||
|
||||
---
|
||||
|
||||
## Clean checks / invariants that do hold
|
||||
|
||||
The review did **not** find a gap in these areas:
|
||||
|
||||
- The canon consistently selects current `mosaicstack/stack` + Drizzle/PostgreSQL and rejects greenfield/Prisma revival.
|
||||
- Every artifact states PostgreSQL is the sole writable SOT and Valkey/files are non-authoritative.
|
||||
- Generated `TASKS.md`, `mission.json`, and exports are consistently declared read-only and never import sources. KBN-300’s importer is scoped to immutable legacy JSON/Vikunja snapshots, not generated projections.
|
||||
- Recovery config exposes recovery fields only; it contains no direct fail-open, SOT, Coordinator-authority, or gate-waiver knob.
|
||||
- The Coordinator interface contains no `createTask`, acceptance-edit, gate-waive, certify, merge, release, or provider-close method. `submitForReview` is type-limited to `in_review`, not `done` or `certified`.
|
||||
- Certifier is consistently final independent gate with no merge authority.
|
||||
- The seven canonical task status values match across requirements, shared prose, schema, and Coordinator’s ready/in-review surfaces.
|
||||
- One-active-lease partial uniqueness, no-self-edge, outbox aggregate-revision/event-type uniqueness, optimistic task/project/mission/milestone versions, and N-1 test categories are explicitly present.
|
||||
- The file-tree partition is mostly well separated once the ordering/freeze defects in KCR-007 are corrected.
|
||||
|
||||
## Required re-review scope
|
||||
|
||||
After remediation, re-review at minimum:
|
||||
|
||||
1. health/coordinator discriminated unions and mutation-time health proof;
|
||||
2. proposal/approval/assignment/lease relational model;
|
||||
3. monotonic fencing and composite bindings;
|
||||
4. tenant-safe polymorphic relationships;
|
||||
5. outage-proposal persistence and commands;
|
||||
6. concrete current-main migration map;
|
||||
7. corrected dependency graph and exact API/DTO freeze;
|
||||
8. recovery validator/owner slice;
|
||||
9. all schema and DTO vocabulary alignment.
|
||||
|
||||
## Overall verdict
|
||||
|
||||
**NO-GO — 8 BLOCKERs must be resolved before the v1 contract is frozen or parallel implementation begins.**
|
||||
38
docs/reports/native-kanban-sot/ultron-final-go.md
Normal file
38
docs/reports/native-kanban-sot/ultron-final-go.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# #751 Native Kanban/SOT canonical publication — Ultron final gate
|
||||
|
||||
**Verdict: GO** — zero BLOCKER/HIGH findings.
|
||||
|
||||
## Scope / integrity
|
||||
|
||||
- Reviewed `/home/hermes/agent-work/stack-kanban-canon` staged delta only: exactly 16 documentation/contract artifacts; no unstaged delta; `git diff --cached --check` passes.
|
||||
- This is a publication canon, not a runtime implementation. The explicit implementation hold prevents feature work until canon merge and prerequisite release (`docs/requirements/native-kanban-sot.md:8-9`; `docs/native-kanban-sot/TASKS.md:45-67`).
|
||||
|
||||
## Acceptance mapping and findings
|
||||
|
||||
| Requirement area | Final evidence / result |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Sole PostgreSQL SOT, generated projections, outage proposals | Requirements D3/D4 and fixed invariants prohibit alternate writers and import (`docs/requirements/native-kanban-sot.md:22-23,32-44`). Health contract keeps public observation separate from branded transaction-local proof (`contracts/health-state.v1.ts:44-84`) and freezes 503/409/502/504 mappings (`:91-184`). Proposal table uses workspace-aware event FKs (`contracts/kanban-schema.v1.ts:847-908`); exact submission/acceptance transaction semantics are specified (`SHARED-CONTRACT.md:81-89`). PASS. |
|
||||
| Workspace tenancy, planning, assignments, evidence | Workspace-composite task and proposal relations plus active-member rules are explicit (`SHARED-CONTRACT.md:40-48`; `kanban-schema.v1.ts:587-637,875-908`). Lease/checkpoint relations bind workspace/task/assignment/session/fence, with one active lease and bigint fencing (`:1062-1114`). PASS. |
|
||||
| Coordinator, gates, concurrency/recovery | Pure Coordinator has snapshot-only decision methods (`mechanical-coordinator.v1.ts:186-198`); persistence port owns locked ID validation and recovery (`:371-407`). Requirements forbid Coordinator scope/gate/certification/merge authority and Certifier merge authority (`requirements:39-40`; `MISSION-MANIFEST.md` authority table). Recovery validator rejects unknown fields, PITR/WAL/RPO/storage/high-assurance violations (`recovery-posture.v1.ts:193-369`). PASS. |
|
||||
| Migration/N-1/API/task decomposition | N-1 expand/backfill/compatibility/switch/contract order and proposal DDL sequence are concrete (`SHARED-CONTRACT.md:69-115`). Frozen exact Gateway/DTO registry and non-overlapping lane ownership/prerequisites are present (`SHARED-CONTRACT.md:244-282`; `TASKS.md:45-67,81-259`). PASS. |
|
||||
| Documentation / seven owner decisions / evidence | D1–D7 are all explicitly ratified (`requirements:20-26`); all 26 REQ sections contain acceptance criteria. Index/manifest/task graph link requirements, frozen contracts, ownership, and evidence. Relative-link audit passes. PASS. |
|
||||
|
||||
## Independent verification performed
|
||||
|
||||
```text
|
||||
git diff --cached --check PASS
|
||||
./node_modules/.bin/prettier --check <all publication paths> PASS
|
||||
./node_modules/.bin/tsc --noEmit --strict <health/coordinator/recovery> PASS
|
||||
Python relative Markdown link audit PASS (0 errors)
|
||||
Python requirement acceptance audit PASS (26 requirements; 0 missing acceptance sections)
|
||||
Static staged scope/status check PASS (16 staged docs-only; no unstaged delta)
|
||||
```
|
||||
|
||||
The full schema-contract strict type check cannot resolve `drizzle-orm` from this docs-only worktree; this is an environment dependency-resolution limitation, not a contract diagnostic. Independent external publication validation and final re-review record the strict all-four-contract check against the current Stack Drizzle toolchain as PASS.
|
||||
|
||||
## Residual items
|
||||
|
||||
- **LOW:** implementation must deliver the declared KBN-100/KBN-110/KBN-140 proposal-event-chain, tenant, failure-mapping, and SecReview evidence before P0/P1 release. This is a forward implementation obligation already frozen in the canon, not a publication defect.
|
||||
- **LOW:** selected infrastructure backup provider/recovery tier and migration/cutover thresholds remain owner-controlled implementation decisions, bounded by the normative recovery contract and change control.
|
||||
|
||||
No source, staging, commit, provider, CI, or deployment state was mutated.
|
||||
368
docs/requirements/native-kanban-sot.md
Normal file
368
docs/requirements/native-kanban-sot.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Native Kanban and Canonical Task SOT — Canonical Requirements
|
||||
|
||||
**Status:** RATIFIED and independently approved for canonical publication under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
**Date:** 2026-07-14
|
||||
**Decision owner:** Jason
|
||||
**Publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage)
|
||||
**Implementation foundation:** current `mosaicstack/stack` main only
|
||||
**Implementation hold:** no feature implementation begins until this canon is squash-merged to `main` with terminal-green CI.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Deliver Mosaic Stack's native project/task control plane and thin writable Kanban on one authoritative PostgreSQL model. This document formalizes the ratified source plan; it does not create a parallel design.
|
||||
|
||||
Normative terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are binding as used here.
|
||||
|
||||
## 2. Ratified decisions
|
||||
|
||||
| # | Ratified decision | Canonical result |
|
||||
| --- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| D1 | Foundation | Extend current `mosaicstack/stack` main with its existing Drizzle/PostgreSQL, NestJS Gateway, Next.js, Better Auth, and Valkey/BullMQ conventions. No greenfield service and no Prisma revival. |
|
||||
| D2 | Tenant boundary | `workspace_id` is the hard tenant boundary from the first migration. Teams are authorization groups inside a workspace, never tenant substitutes. |
|
||||
| D3 | Outage authority — Option A with amendment | PostgreSQL is the sole writable SOT and mutations fail closed whenever DB write-health cannot be proven. The amendment permits deployment-specific **recovery posture only**; it does not permit an alternate writer. Human outage notes are attributable post-recovery proposals, never shadow state. |
|
||||
| D4 | Generated files | `TASKS.md`, `mission.json`, and any file export are generated, read-only, non-authoritative, and never import sources. Generate on demand; commit only where repository review policy requires a snapshot. |
|
||||
| D5 | Status model | Task statuses are `backlog`, `ready`, `in_progress`, `blocked`, `in_review`, `done`, `cancelled`. Runtime readiness is orthogonal and computed. |
|
||||
| D6 | Coordinator approval | Hybrid: manual Project Sub-Orchestrator approval by default; automatic routing only under an explicit, approved, versioned low-risk policy. |
|
||||
| D7 | Initial migration scope | Project, mission, milestone, task, tags/archive, dependency, assignment, outage proposal, evidence/link, and orchestration state only. Calendar, email, GLPI cache, and personal-brain features remain out of scope. |
|
||||
|
||||
## 3. Fixed invariants — every deployment
|
||||
|
||||
These are not tier settings and cannot be weakened by deployment configuration.
|
||||
|
||||
1. PostgreSQL is the **sole writable source of truth**.
|
||||
2. The implementation uses Drizzle on current stack main.
|
||||
3. Kanban and orchestration mutations **fail closed** unless DB write-health is positively proven `healthy`.
|
||||
4. No failed mutation is redirected to Markdown, JSON, browser storage, Valkey, queue payloads, scratchpads, or provider issues.
|
||||
5. `TASKS.md` and all file exports are generated, read-only, non-authoritative, and never parsed for import.
|
||||
6. Human notes created during an outage become attributable proposals only after recovery. They do not reserve work, change status, satisfy a gate, or establish ordering.
|
||||
7. Valkey is derived, expendable coordination infrastructure. PostgreSQL retains task truth, leases, fencing, audit, and the transactional outbox.
|
||||
8. The Mechanical Coordinator is non-LLM and deterministic. It may evaluate eligibility, dependencies, approval policy, leases, fencing, heartbeat, retry, expiry, and quarantine. It cannot invent scope, alter acceptance criteria, waive gates, certify, or merge.
|
||||
9. **Certifier** is the final independent quality-gate role. Certifier may pass, reject, or escalate with evidence; it has no merge authority.
|
||||
10. Every business and orchestration record is workspace-scoped; cross-workspace relationships are rejected.
|
||||
11. Every mutation is idempotent and expected-version checked where it changes an aggregate.
|
||||
12. Stale worker mutations are rejected by monotonically increasing fencing tokens.
|
||||
13. Audit events are append-only and attributable; authoritative state is reconstructable from PostgreSQL without Valkey or files.
|
||||
|
||||
## 4. Configurable recovery posture only
|
||||
|
||||
Deployment tiers configure durability and operational recovery targets. They never configure SOT authority, fail-open writes, or gate bypass.
|
||||
|
||||
### 4.1 Tier defaults
|
||||
|
||||
| Setting | Lite | Standard | High-assurance |
|
||||
| --------------------------- | --------------------------------------: | ------------------------------------------------------------: | ----------------------------------------------------------------------: |
|
||||
| Target RPO | 24 hours | 1 hour | **15 minutes** |
|
||||
| Target RTO | 24 hours | 8 hours | **4 hours** |
|
||||
| Base backup cadence | Daily | Daily | **Daily** |
|
||||
| WAL archive cadence | Disabled | Every 15 minutes | **Every 5 minutes** |
|
||||
| PITR retention | 0 days / disabled | 14 days | **35 days** |
|
||||
| Restore test frequency | Quarterly | Quarterly | **Monthly** |
|
||||
| Break-glass drill frequency | Annually | Semiannually | **Quarterly** |
|
||||
| Off-cluster storage | One encrypted off-cluster backup target | Encrypted off-cluster object storage, separate failure domain | **Encrypted off-cluster base backups and WAL, separate failure domain** |
|
||||
|
||||
A deployment MAY override defaults only through the validated recovery-posture contract. An override MUST record actor, reason, effective time, and policy revision. A claimed RPO MUST be no smaller than the actual backup/WAL mechanism can support. Enabling PITR requires WAL archival and off-cluster storage.
|
||||
|
||||
## 5. Functional requirements and acceptance criteria
|
||||
|
||||
### REQ-SOT-001 — Sole writable PostgreSQL authority
|
||||
|
||||
**Requirement:** All project, mission, milestone, task/tag/archive, dependency, assignment, execution/quarantine, lease, checkpoint, approval, outage proposal, event, link, artifact, and outbox mutations MUST commit through Gateway domain services into PostgreSQL.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Mutation journey tests show web, CLI, MCP, and agents invoke typed Gateway commands.
|
||||
- Static/process inventory finds no file, Valkey, browser, or provider issue writer acting as canonical state.
|
||||
- PostgreSQL state survives Valkey loss and reconstructs the same aggregate revisions.
|
||||
|
||||
### REQ-SOT-002 — Fail-closed mutation health
|
||||
|
||||
**Requirement:** A mutation MUST execute only while health state is `healthy`. `read-only-degraded` and `write-unavailable` MUST return the frozen deliberate-denial error contract and MUST NOT enqueue a hidden write.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Public health response is a discriminated union; contradictory state/proof combinations fail contract validation.
|
||||
- Mutation methods accept only a fresh internal PostgreSQL transaction-local write proof, never caller-asserted/public health state.
|
||||
- Negative tests reject expired proofs, policy-revision mismatch, Valkey-only liveness, and caller-forged `healthy`.
|
||||
- Fault tests force both degraded states and prove row counts, outbox, files, and Valkey remain unchanged.
|
||||
- Exact failure mapping proves authoritative 503 denial, retryable 502/504/timeout uncertainty, and 409 version conflict cannot cross-map.
|
||||
- Replaying the same idempotency key after recovery returns one canonical result.
|
||||
|
||||
### REQ-SOT-003 — Generated projections
|
||||
|
||||
**Requirement:** `TASKS.md`, `mission.json`, and other exports MUST contain a non-authoritative header, workspace/project IDs, generated time, and source revision. No production parser may mutate DB from an export.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Generated output matches the API snapshot revision.
|
||||
- Hand editing a projection fails CI validation or is overwritten by regeneration.
|
||||
- Repository search finds no import path from generated projections.
|
||||
|
||||
### REQ-SOT-004 — Attributable outage proposals
|
||||
|
||||
**Requirement:** Human outage notes MAY be captured outside the system but, after recovery, can enter Mosaic only through workspace-scoped `change_proposals` attributed to an authenticated active member. A proposal stores source-note digest, target aggregate/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, and audit links. It MUST NOT silently change canonical state.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` are composite foreign keys to `task_events(workspace_id, id)`; missing and foreign-workspace event IDs fail before commit.
|
||||
- Submission preallocates the proposal ID and atomically inserts `change_proposal.submitted` for that exact workspace/proposal with the new proposal referencing it.
|
||||
- Accept locks proposal and target, obtains fresh write proof, checks expected version, executes the normal typed command, and atomically links that command's event for the same workspace/target and proposal causation.
|
||||
- Negative tests reject missing submission events, foreign-workspace submission/acceptance events, and same-workspace events for an unrelated proposal, aggregate, target, or command.
|
||||
- Tests prove a pending/rejected proposal cannot claim/order work, satisfy a dependency/gate, or mutate any target directly.
|
||||
|
||||
### REQ-TEN-001 — Workspace hard tenancy
|
||||
|
||||
**Requirement:** Every canonical business/orchestration row MUST carry `workspace_id`. Workspace-aware constraints and authorization MUST prevent cross-tenant relationships and reads/writes.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- API, repository, import, WebSocket, and Coordinator negative tests reject foreign-workspace IDs without existence oracles.
|
||||
- Project/task owners use exactly-one user/team references; assignment principals use exactly-one user/team/agent reference; agent/session targets are workspace-consistent.
|
||||
- User owners, principals, proposers, and decision actors require ACTIVE workspace membership in the authoritative transaction.
|
||||
- Dependency, project hierarchy, assignment, lease, checkpoint, approval-evidence, link, artifact, proposal target, and both proposal-audit-event composite relationships reject mixed workspaces.
|
||||
- Tenant context is derived from authenticated authority, never accepted blindly from request data.
|
||||
|
||||
### REQ-ID-001 — Workspace identity and service scope
|
||||
|
||||
**Requirement:** Users, teams, agents, and agent sessions MUST be bound to a workspace with explicit role/capability scope. Agents MUST NOT receive raw DB credentials.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Workspace membership and service-identity tests enforce command-family scope.
|
||||
- Revoked/disabled agents and ended sessions cannot claim, heartbeat, or submit.
|
||||
|
||||
### REQ-PLAN-001 — Normalized planning hierarchy
|
||||
|
||||
**Requirement:** Canonical planning entities are projects, milestones, missions, mission-milestone associations, and tasks. A task belongs to one required project and at most one mission/milestone/parent task.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- CRUD tests preserve workspace, hierarchy, versions, and lifecycle constraints.
|
||||
- Mission membership does not duplicate task status.
|
||||
- Composite project-congruent constraints reject task→mission, task→milestone, task→parent, mission→milestone, and project→current-milestone mismatches.
|
||||
- Parent and association constraints reject cycles/orphans where applicable.
|
||||
|
||||
### REQ-TASK-001 — Canonical task fields
|
||||
|
||||
**Requirement:** Tasks MUST support title, description, structured acceptance criteria, canonical status, priority, fractional board rank, accountable owner, assigned specialist role, due/not-before dates, estimate, progress, explicit blocker, retry policy, normalized workspace tags, non-lifecycle archival (`archived_at/by/reason`), metadata, monotonic fencing counter, and optimistic version.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- API and UI round-trip every field without silent loss.
|
||||
- Current `tasks.tags`, `assignee`, and `due_date` remain declared/preserved during N-1 and backfill to the canonical model without loss.
|
||||
- Archive hides work without changing its canonical lifecycle status and requires actor/reason/time.
|
||||
- Invalid status, rank, progress, date, owner, tag, archive, or retry data is rejected.
|
||||
- Concurrent expected-version updates produce a visible conflict.
|
||||
|
||||
### REQ-TASK-002 — Fixed lifecycle and computed readiness
|
||||
|
||||
**Requirement:** Human workflow status MUST use the seven ratified values. Dependency/schedule/policy/lease/retry conditions MUST be exposed as computed readiness, not hidden status rewrites.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- A dependency becoming incomplete changes readiness but does not silently rewrite the Kanban column.
|
||||
- Readiness explanation identifies all active gates.
|
||||
- State-machine tests reject illegal transitions and require reasons for blocked/cancelled paths.
|
||||
|
||||
### REQ-DEP-001 — Dependency DAG
|
||||
|
||||
**Requirement:** Workspace-local directed dependencies MUST be unique and acyclic. A task is dependency-eligible only after every blocking predecessor is `done` and completion conditions pass.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- `(workspace_id, predecessor_task_id, successor_task_id)` is unique independent of dependency type.
|
||||
- Cycle, duplicate, self-edge, and cross-workspace attempts fail before commit.
|
||||
- Property/concurrency tests prove all blocking predecessors are evaluated.
|
||||
- UI displays dependency and readiness errors accessibly.
|
||||
|
||||
### REQ-ASN-001 — Assignment is not a lease
|
||||
|
||||
**Requirement:** Assignment history and execution leases MUST be separate records. One persisted assignment identity freezes task version, exact target agent/session (or exactly-one non-agent principal), specialist role, expiry, state, policy revision, proposer, reason, and timestamps. Approval decisions relate to that assignment with workspace-aware constraints.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- One assignment-state vocabulary is identical across schema, DTO, and engine.
|
||||
- Lease acquisition accepts IDs only, then reloads and locks assignment, approval, task, and target session to verify workspace, current task version, exact target, state, expiry, and policy revision.
|
||||
- Reassignment preserves history; assignment may exist without a lease; lease expiry does not erase ownership/evidence.
|
||||
|
||||
### REQ-AUD-001 — Semantic audit and outbox
|
||||
|
||||
**Requirement:** Mutating commands MUST append semantic `task_events` with actor, correlation, causation, idempotency key, and aggregate versions in the same transaction as state. Notifications MUST flow from a transactional outbox.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Atomicity tests prove state/event/outbox commit or roll back together.
|
||||
- Proposal submission and acceptance tests prove their workspace-bound event links identify the exact submission and executed normal command, not merely an existing event UUID.
|
||||
- Duplicate idempotency keys return the prior result without duplicate events.
|
||||
- `task_events`, checkpoints, immutable artifacts, and evidence joins are INSERT/SELECT-only for application roles; parent hard deletes are RESTRICTed.
|
||||
- Normal lifecycle uses archive/cancel, never hard delete; retention purge requires audited break-glass authority and evidence.
|
||||
- Valkey outage leaves outbox pending and later replayable.
|
||||
|
||||
### REQ-API-001 — Typed Gateway command boundary
|
||||
|
||||
**Requirement:** Gateway MUST expose workspace-safe project/task/dependency/assignment/link/artifact/change-proposal queries and explicit lifecycle commands. Generic patching MUST NOT bypass claim, heartbeat, review, certify, proposal acceptance, or completion invariants.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- KBN-105 freezes exact route, request, success, denial, conflict, and transport-normalization DTOs before CLI/web implementation.
|
||||
- DTO validation, authorization, contract, and integration tests cover each command.
|
||||
- Exact MCP-owned Gateway files are coder3-owned; coder4 consumes only frozen Gateway contracts.
|
||||
- Endpoint registry aligns web, CLI, MCP, and generated client paths.
|
||||
- Direct SQL and raw Valkey writes are absent from clients.
|
||||
|
||||
### REQ-UI-001 — Writable thin Kanban/List MVP
|
||||
|
||||
**Requirement:** Existing Tasks and Projects surfaces MUST become a real-data writable MVP with one shared query contract.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Users can create/edit/cancel/archive tasks, open task detail, and move cards within/across columns.
|
||||
- Server validates transition and persists fractional board rank.
|
||||
- Refresh, reconnect, CLI, MCP, and generated projection show the same revision.
|
||||
|
||||
### REQ-UI-002 — Tenant and work context
|
||||
|
||||
**Requirement:** UI MUST show workspace context and support filters for project, mission, milestone, status, priority, owner/specialist, due state, and tags.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Context is visible on every mutation surface.
|
||||
- Filter tests cannot expose foreign-workspace data.
|
||||
- Empty/loading/error states are explicit.
|
||||
|
||||
### REQ-UI-003 — Dependency, ownership, lease, and audit visibility
|
||||
|
||||
**Requirement:** Task detail MUST separate accountable owner, specialist assignment, active session/lease expiry, dependencies/readiness, acceptance criteria, blocker, external links, and audit timeline.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Each concept renders from its canonical endpoint.
|
||||
- A lease is never displayed as ownership or completion.
|
||||
- Conflict and stale-reconnect states require refresh rather than silent overwrite.
|
||||
|
||||
### REQ-UI-004 — Accessible interaction
|
||||
|
||||
**Requirement:** Kanban MUST support keyboard-accessible moves, non-drag alternatives, responsive layout, and semantic status/error announcements.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Keyboard journey performs every card transition available by drag.
|
||||
- Automated accessibility checks and manual responsive checks pass.
|
||||
|
||||
### REQ-COORD-001 — Non-LLM Mechanical Coordinator
|
||||
|
||||
**Requirement:** Coordinator decisions MUST be deterministic from structured data and versioned policy. It MUST NOT invoke an LLM to interpret scope or acceptance criteria.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Pure decision engine receives complete immutable snapshots and performs no ID loading, SQL, Gateway, Valkey, or recovery I/O.
|
||||
- Persistence/service adapter owns ID loading, transaction-local write proof, locking, persistence, and `recoverFromPostgres`.
|
||||
- Same snapshot and policy revision produce the same eligibility/order explanation.
|
||||
- Dependency, schedule, durable retry/quarantine, approval, role, and capacity inputs are auditable.
|
||||
- Code/config inspection finds no model/provider dependency in the scheduling engine.
|
||||
|
||||
### REQ-COORD-002 — Eligibility and approval routing
|
||||
|
||||
**Requirement:** Only `ready` tasks under active project/mission, passed dependencies/schedule/retry/release policy, and without active lease may be proposed. Manual approval is default; auto-route requires an explicit approved policy revision.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Unapproved or gated tasks are never leased.
|
||||
- Every persisted assignment proposal includes task version, exact target agent/session, expiry, state, deterministic reasons, and policy revision.
|
||||
- Approval is relationally bound to the assignment identity and cannot be supplied as a forgeable proof-by-value DTO.
|
||||
- Override/reject/reassign requires an attributable reason.
|
||||
|
||||
### REQ-COORD-003 — Atomic lease, heartbeat, fencing, and recovery
|
||||
|
||||
**Requirement:** Lease acquisition MUST be atomic in PostgreSQL, permit at most one active lease per task, atomically increment the durable per-task fencing counter under task lock, use bigint-safe tokens, require timely acknowledgement/heartbeat, and reject stale workers. Lease and checkpoint relations MUST bind the exact workspace+task+assignment/session+fence.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Concurrent claim tests yield one winner and strictly increasing fencing tokens.
|
||||
- Lower/expired tokens and mismatched same-workspace task/assignment/lease/checkpoint IDs fail.
|
||||
- Token values round-trip as bigint/decimal strings without JavaScript precision loss.
|
||||
- Coordinator restart reconstructs lease/retry/quarantine state from PostgreSQL alone.
|
||||
|
||||
### REQ-COORD-004 — Retry and quarantine
|
||||
|
||||
**Requirement:** Missing acknowledgement, agent loss, or execution failure MUST produce a deterministic release, bounded backoff retry, or quarantine outcome according to retry policy. Ambiguous/non-idempotent work requires Sub-Orchestrator action.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Durable execution state records disposition, attempt/max, next eligibility, terminal reason, actor/policy, timestamps, and version.
|
||||
- Retry budget/backoff are bounded and tested.
|
||||
- Exhausted or non-idempotent failures quarantine with workspace-scoped artifact evidence.
|
||||
- One specialist-role vocabulary is enforced across schema, sessions, assignments, DTOs, and engine.
|
||||
- No task loops indefinitely or silently returns to ready.
|
||||
|
||||
### REQ-GATE-001 — Role and authority chain
|
||||
|
||||
**Requirement:** Canonical flow is User → Interaction → Portfolio Orchestrator → Project Sub-Orchestrator → Gateway → domain services → Mechanical Coordinator → specialists → Certifier.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Role bindings and approvals are queryable and audited.
|
||||
- Coordinator cannot create scope or waive gates.
|
||||
- Certifier cannot merge or close provider artifacts.
|
||||
|
||||
### REQ-GATE-002 — Independent review and certification
|
||||
|
||||
**Requirement:** Author and reviewer MUST differ. Auth, security, tenant, secrets, and data-integrity surfaces MUST receive mandatory SecReview. Certifier is the final quality gate after remediation.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Gate tests reject author self-review and missing required SecReview.
|
||||
- Certifier receives complete traceability/evidence and returns pass/reject/escalate.
|
||||
- A Certifier pass does not grant merge authority.
|
||||
|
||||
### REQ-REC-001 — Recovery posture validation
|
||||
|
||||
**Requirement:** A deployment MUST select a validated Lite, Standard, or High-assurance posture and MAY override only recovery knobs.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Runtime invokes normative `validateRecoveryPostureV1`, not shape-only JSON Schema validation.
|
||||
- Validator rejects PITR/WAL mismatch, impossible RPO, unknown fields, non-encrypted/non-separated storage, and weakened High-assurance values.
|
||||
- A bounded recovery/infra slice owns parser wiring, override audit, mechanism verification, restore test, and break-glass evidence.
|
||||
- High-assurance defaults equal RPO 15m/RTO 4h, encrypted off-cluster WAL every 5m, 35d PITR, daily base backup, monthly restore test, and quarterly break-glass.
|
||||
|
||||
### REQ-MIG-001 — One-way shadow migration
|
||||
|
||||
**Requirement:** Migration from jarvis-brain/Vikunja MUST use inventory, immutable source snapshots/checksums, one-way shadow import, read reconciliation, write freeze, final delta, cutover, and read-only stabilization. Dual writes are forbidden.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- P0 publishes the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract map before any schema lane starts.
|
||||
- Legacy columns remain in the unified Drizzle declaration for the entire expand/N-1 window.
|
||||
- Dry-run/apply/verify modes are idempotent and workspace-safe.
|
||||
- Import lineage preserves source system/key/file/checksum/batch and rejected-record reports.
|
||||
- Empty DB, production-shape, partial-resume, downgrade/rollback, status-shadow, workspace-backfill, and `mission_tasks.status` retirement tests pass.
|
||||
- Shadow records cannot auto-dispatch.
|
||||
|
||||
### REQ-MIG-002 — Cutover and rollback safety
|
||||
|
||||
**Requirement:** Cutover MUST disable legacy writers and switch all clients to Gateway. Before first DB mutation rollback may switch authority back; afterward rollback requires freeze, DB-delta export/reconciliation, and owner decision.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Process inventory proves no active jarvis-brain/Vikunja project/task writer.
|
||||
- Cutover rehearsal meets signed reconciliation thresholds.
|
||||
- No reverse and forward sync run concurrently.
|
||||
|
||||
## 6. Explicit non-goals
|
||||
|
||||
The P0–P3 canon does not authorize:
|
||||
|
||||
- replacing Gitea issue/PR storage;
|
||||
- calendar, email, GLPI cache, CRM, billing, time tracking, or personal-brain migration;
|
||||
- arbitrary custom workflows/statuses/fields;
|
||||
- a writable offline/file/Valkey/browser fallback;
|
||||
- direct client database access;
|
||||
- LLM scheduling or autonomous scope invention;
|
||||
- Coordinator gate waiver, certification, merge, release, or provider issue closure;
|
||||
- Certifier merge authority;
|
||||
- full mission designer, portfolio analytics, critical-path UX, or advanced board customization in the thin MVP;
|
||||
- P4/P5 features unless separately released.
|
||||
|
||||
## 7. Global release evidence
|
||||
|
||||
P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
|
||||
43
docs/scratchpads/747-wsa-dehardcode.md
Normal file
43
docs/scratchpads/747-wsa-dehardcode.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# #747 — De-hardcode orchestrator and interaction agent names
|
||||
|
||||
## Objective
|
||||
|
||||
Replace branded Mos/Tess symbols, filenames, DI tokens, and error prose with role-neutral orchestrator/interaction vocabulary without changing env-driven runtime identity behavior. Add optional roster `alias` and `provider` fields and show aliases in `mosaic fleet ps` with name fallback.
|
||||
|
||||
## Scope and constraints
|
||||
|
||||
- Requirements: `/home/hermes/agent-work/reviews/747-wsa-dehardcode-brief.md`.
|
||||
- Branch: `feat/747-dehardcode-orchestrator-interaction-names` from `main` at `e72388b2`.
|
||||
- Keep `MOSAIC_AGENT_NAME` and `MOSAIC_ORCHESTRATOR_AGENT_NAME` unchanged.
|
||||
- Sample/test data may retain operator display names.
|
||||
- No behavior change beyond optional roster metadata and alias display.
|
||||
- Budget: no explicit cap; conservative mechanical-rename scope only.
|
||||
- TDD: optional and skipped because this is a mechanical rename with existing focused coverage; add focused alias/schema regression coverage before completion.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Rename coordination and durable-session files and symbols using canonical vocabulary.
|
||||
2. Scrub branded symbol names and error prose in the assigned source trees while preserving allowed sample data.
|
||||
3. Extend roster schema with optional `alias` and `provider`; update fleet roster typing/rendering and focused tests.
|
||||
4. Run grep-clean verification, build, typecheck, lint/format, focused coord/durable-session/fleet tests, and roster validation.
|
||||
5. Commit, queue-guard, push, open a Gitea PR closing #747, and report to the coordinator.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-07-13: Task resumed from coordinator brief; repository clean at `e72388b2`.
|
||||
- Renamed coordination and durable-session files, exports, gateway DI symbols, DTOs, services, repositories, and tests.
|
||||
- Replaced branded authority/error prose while preserving the existing `/api/coord/mos` compatibility route and env-variable identity inputs.
|
||||
- Added optional roster `alias`/`provider` support and alias-first `fleet ps` display with canonical-name fallback.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm typecheck`: passed (42 tasks).
|
||||
- `pnpm build`: passed (23 tasks).
|
||||
- `pnpm lint`: passed (23 tasks).
|
||||
- `pnpm format:check`: passed.
|
||||
- Coordination tests: 7 passed.
|
||||
- Agent durable-session/runtime tests: 23 passed.
|
||||
- Gateway coordination/durable-session/integration tests: 20 passed.
|
||||
- Full `fleet.spec.ts`: 192 passed, including alias/provider parsing and alias display.
|
||||
- JSON Schema 2020 validation: legacy minimal roster and extended alias/provider roster passed; `alias` and `provider` remain absent from `required`.
|
||||
- Grep verification: no branded symbol/type/file/DI names or error prose remain in assigned source trees; one allowed `Tess Owner` test-data display name remains.
|
||||
152
docs/scratchpads/751-native-kanban-canon.md
Normal file
152
docs/scratchpads/751-native-kanban-canon.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Issue #751 — Native Kanban/SOT canonical publication
|
||||
|
||||
## Objective
|
||||
|
||||
Publish the owner-ratified P0–P3 requirements, mission manifest, task decomposition, and frozen shared contracts before feature implementation.
|
||||
|
||||
## Authority and decisions
|
||||
|
||||
- Owner: Jason
|
||||
- Plan owner/orchestrator: web1 control plane; takeover by mosaic-100 during Claude quota outage
|
||||
- Tracking: Mosaic Stack issue #751
|
||||
- Foundation: current Stack main + Drizzle/PostgreSQL
|
||||
- Fixed invariants: PostgreSQL sole writable SOT; writes fail closed; exports never import; outage notes become attributable proposals; mechanical Coordinator has no scope/gate/certify/merge authority; Certifier has no merge authority.
|
||||
- Recovery posture only is configurable through Lite, Standard, and High-assurance profiles.
|
||||
|
||||
## Execution log
|
||||
|
||||
- 2026-07-14: Existing planner-sol canon remediation reviewed from staging. KCR-001–016 claimed resolved; static checks passed.
|
||||
- 2026-07-14: Independent GPT/Terra re-review dispatched to rev1.
|
||||
- 2026-07-14: Re-review returned NO-GO: proposal audit-event IDs were not workspace-bound, leaving attribution forgeable; formatter evidence was not reproducible. Focused remediation round 2 routed to planner-sol.
|
||||
- 2026-07-14: Remediation bound proposal audit links to `task_events(workspace_id,id)`, froze same-transaction semantic validation and negative tests, and made formatter/type/static checks reproducible.
|
||||
- 2026-07-14: Independent rev1 re-review returned GO with KCR-001–016 closed and no new blocker. Canon copied into the issue #751 publication worktree; feature implementation remains held until merge.
|
||||
- 2026-07-14: Independent publication validation returned FAIL on formatting/trailing whitespace, stale staging wording, ignored review evidence, and missing worktree dependencies. Bounded publication remediation routed to planner-sol; no runtime source change authorized.
|
||||
- 2026-07-14: Publication remediation installed locked dependencies outside the repository cache, fixed formatting and wording, and preserved docs-only scope. Independent gaterun revalidation returned PASS across staged scope, formatting, lint, typecheck, strict contract compile, links, rollups, review artifacts, and fixed invariants.
|
||||
- 2026-07-14: Ultron final gate returned GO with zero BLOCKER/HIGH findings; residual LOW items remain explicit implementation obligations.
|
||||
- 2026-07-14: First commit attempt was correctly blocked by the lint-staged hook because docs contract `.ts` files were outside TypeScript project-service scope. Added a strict no-emit workstream `tsconfig.json` with exact Drizzle declaration paths; targeted contract TSC, contract ESLint, format, full lint/typecheck, strictness, and docs-only scope independently passed.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- Initial review: `docs/reports/native-kanban-sot/canon-initial-review-no-go.md`
|
||||
- Final GO: `docs/reports/native-kanban-sot/canon-final-rereview-go.md`
|
||||
- Ultron GO: `docs/reports/native-kanban-sot/ultron-final-go.md`
|
||||
- Pending: PR gates, squash merge, terminal-green CI, and issue closure.
|
||||
|
||||
## Publication remediation session — planner-sol
|
||||
|
||||
**Assignment:** Remediate only issue #751 publication blockers from `/home/hermes/agent-work/reviews/751-kanban-canon-publication-validation.md`; no source, package, lock, provider, CI, build, deploy, commit, or push action.
|
||||
|
||||
**Plan:**
|
||||
|
||||
1. Install the locked pnpm dependencies without modifying package metadata.
|
||||
2. Replace only stale publication/staging wording while retaining the canon-merge and KBN prerequisite implementation hold.
|
||||
3. Format all changed candidate Markdown and contract TypeScript; remove trailing whitespace.
|
||||
4. Run worktree `format:check`, `lint`, `typecheck`, strict no-emit contract compilation, tracked/untracked whitespace checks, and static invariants.
|
||||
5. Verify package/lock/source paths are unchanged and append exact evidence here.
|
||||
|
||||
**Budget:** No explicit token cap; bounded docs-only remediation, no exploratory/runtime work.
|
||||
**TDD:** Not applicable—documentation/contract-publication formatting and wording only; strict/static validation is the primary gate.
|
||||
|
||||
## Publication remediation results
|
||||
|
||||
### Changes
|
||||
|
||||
- `docs/native-kanban-sot/INDEX.md`: replaced staging/pending-GO wording with current publication and independent-GO wording; retained the merge hold and dependency-ordered KBN prerequisite hold.
|
||||
- `docs/native-kanban-sot/TASKS.md`: replaced “Mos using this staging set” with “Mos / publication control plane”; made the post-merge KBN prerequisite hold explicit.
|
||||
- Formatted all changed candidate Markdown and four contract TypeScript files with current-worktree Prettier 3.8.1.
|
||||
- Removed trailing whitespace from candidate Markdown, including both linked review reports.
|
||||
- Preserved both review reports and their links; they remain ignored by `.gitignore:11` for coordinator force-tracking.
|
||||
|
||||
### Dependency installation
|
||||
|
||||
The first target-worktree install attempt used the environment's default root-owned pnpm store and failed without changing package metadata:
|
||||
|
||||
```text
|
||||
cd /home/hermes/agent-work/stack-kanban-canon && pnpm install --frozen-lockfile
|
||||
EACCES: permission denied, open '/root/.local/share/pnpm/store/v10/server/server.json'
|
||||
```
|
||||
|
||||
Successful locked install using an authorized cache outside the repository:
|
||||
|
||||
```bash
|
||||
cd /home/hermes/agent-work/stack-kanban-canon
|
||||
pnpm install --frozen-lockfile --store-dir /home/hermes/agent-work/pnpm-store
|
||||
```
|
||||
|
||||
Result: PASS, 1,240 packages installed; lockfile resolution skipped as up to date. `node_modules` remains ignored. Tool versions: pnpm 10.6.2, Prettier 3.8.1, TypeScript 5.9.3, Drizzle ORM 0.45.1, Turbo 2.8.16.
|
||||
|
||||
### Exact quality-gate results
|
||||
|
||||
```text
|
||||
pnpm format:check
|
||||
PASS — All matched files use Prettier code style.
|
||||
|
||||
pnpm lint
|
||||
PASS — 23 successful lint tasks.
|
||||
|
||||
pnpm typecheck
|
||||
PASS — 42 successful tasks. Turbo invoked configured dependency build prerequisites as part of the repository's exact typecheck graph; no standalone build command was run.
|
||||
```
|
||||
|
||||
Candidate formatting commands:
|
||||
|
||||
```bash
|
||||
pnpm exec prettier --write <3 tracked rollups + 9 native-kanban artifacts + requirements + scratchpad>
|
||||
pnpm exec prettier --check <same files>
|
||||
pnpm exec prettier --ignore-path /dev/null --write \
|
||||
docs/reports/native-kanban-sot/canon-initial-review-no-go.md \
|
||||
docs/reports/native-kanban-sot/canon-final-rereview-go.md
|
||||
pnpm exec prettier --ignore-path /dev/null --check \
|
||||
docs/reports/native-kanban-sot/canon-initial-review-no-go.md \
|
||||
docs/reports/native-kanban-sot/canon-final-rereview-go.md
|
||||
```
|
||||
|
||||
Result: PASS. The explicit `/dev/null` ignore path is required because `docs/reports/` is intentionally ignored pending coordinator force-tracking.
|
||||
|
||||
Tracked and untracked whitespace checks:
|
||||
|
||||
```text
|
||||
git diff --check
|
||||
PASS
|
||||
|
||||
git diff --no-index --check /dev/null <each untracked/ignored candidate>
|
||||
PASS for all candidates
|
||||
```
|
||||
|
||||
Strict contract compilation initially could not resolve pnpm-isolated `drizzle-orm` from the external docs directory. A temporary, removed dependency-context symlink made current-worktree resolution explicit:
|
||||
|
||||
```bash
|
||||
LINK=docs/native-kanban-sot/node_modules
|
||||
ln -s ../../packages/db/node_modules "$LINK"
|
||||
trap 'unlink "$LINK"' EXIT
|
||||
pnpm exec tsc \
|
||||
--noEmit \
|
||||
--strict \
|
||||
--skipLibCheck \
|
||||
--target ES2022 \
|
||||
--module NodeNext \
|
||||
--moduleResolution NodeNext \
|
||||
docs/native-kanban-sot/contracts/*.ts
|
||||
```
|
||||
|
||||
Result: `strict-contract-noemit=PASS`; temporary link removed.
|
||||
|
||||
Static result:
|
||||
|
||||
```text
|
||||
proposal-audit-links=PASS
|
||||
kcr-invariant-regression=PASS
|
||||
publication-wording=PASS
|
||||
vocabulary-alignment=PASS
|
||||
```
|
||||
|
||||
### Scope-integrity evidence
|
||||
|
||||
Baseline and final hashes are identical:
|
||||
|
||||
```text
|
||||
package.json 93a50eaefc7a0446a56234e427df03f6a2256f8da17c0bede17c22206928c8c0
|
||||
pnpm-lock.yaml 8b6448d51ac7797c8f782af52a080c0e38ab8bf364f32624f94e636bf5743229
|
||||
```
|
||||
|
||||
`tracked-package-lock-source-unchanged=PASS`: every tracked/untracked nonignored change remains under `docs/`; no package, lock, application source, plugin source, configuration, CI trigger, standalone build/deploy, container, provider, commit, or push action occurred.
|
||||
55
docs/scratchpads/753-kbn010-threat-gate.md
Normal file
55
docs/scratchpads/753-kbn010-threat-gate.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Issue #753 — KBN-010 threat, authorization, and constraint-impact gate
|
||||
|
||||
## Objective
|
||||
|
||||
Complete the mandatory threat/auth/constraint-impact analysis that gates KBN-100 schema implementation.
|
||||
|
||||
## Scope
|
||||
|
||||
- In: threat matrix, frozen-control mapping, schema/API/test impact inventory, security evidence plan.
|
||||
- Out: runtime, schema, migration, API, dependency, CI, deployment, and secret changes.
|
||||
- Canonical requirements: `docs/requirements/native-kanban-sot.md`.
|
||||
- Frozen contract: `docs/native-kanban-sot/SHARED-CONTRACT.md` and `contracts/*.v1.ts`.
|
||||
- Tracking: Mosaic Stack issue #753.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Independently inspect current-main implementation and frozen canon.
|
||||
2. Enumerate tenant, identity, health-proof, approval, fencing, proposal, audit, token, and outage threats.
|
||||
3. Map every threat to required constraints, command behavior, negative tests, and owning future slice.
|
||||
4. Surface any unresolved schema impact as a blocker; do not silently amend the frozen contract.
|
||||
5. Run documentation/static validation and submit for independent SecReview.
|
||||
|
||||
## Execution log
|
||||
|
||||
- 2026-07-14: KBN-000 completed through PR #752 and post-merge pipeline #1798.
|
||||
- 2026-07-14: KBN-010 issue #753 created; task marked in progress; fresh GPT worker pending dispatch.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
Pending worker validation, independent SecReview, Ultron gate, PR merge, post-merge CI, and issue closure.
|
||||
|
||||
## 2026-07-14 worker analysis checkpoint
|
||||
|
||||
- Inspected issue #753, canonical requirements, all frozen v1 contracts, and actual `origin/main` at `49e8a54` across DB schema, Better Auth scope, project/task/mission/team repositories/controllers, fleet backlog, and `TASKS.md` parsing/writing.
|
||||
- Confirmed the worker branch has no source/runtime/schema delta from `origin/main`; orchestrator-owned `.mosaic` state remains dirty and untouched.
|
||||
- Authored the threat, authorization, constraint-impact, negative-test, and requirements-traceability analysis in `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md`.
|
||||
- Gate decision: **BLOCKED** by `KBN010-SI-001`. `missionsV1` lacks a unique candidate key on `(workspace_id, id)`, while `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` both reference that exact pair. PostgreSQL cannot create the frozen composite foreign keys as declared.
|
||||
- Decision: do not select or apply a schema fix. Contract authority must version either a `(workspace_id, id)` mission candidate key or project-congruent child keys, then obtain independent re-review before KBN-100.
|
||||
- Additional risks are controlled by frozen transaction/API behavior but require the exact future negative tests cataloged in the deliverable, especially active membership, service-token revocation, same-workspace semantic evidence checks, no-oracle behavior, and serialized parent/DAG cycle checks.
|
||||
- OpenBrain startup recall was attempted but unavailable because `/home/hermes/.config/mosaic/credentials.json` is absent; no project state was written to an alternate memory silo.
|
||||
- TDD: not applicable; this slice changes documentation/test-plan analysis only and implements no runtime behavior.
|
||||
|
||||
## Validation log
|
||||
|
||||
- `pnpm exec prettier --check docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md docs/scratchpads/753-kbn010-threat-gate.md` — PASS.
|
||||
- Changed-doc link validator — PASS (`relative_links=0`, one external issue link); `curl -fsSIL https://git.mosaicstack.dev/mosaicstack/stack/issues/753` — PASS.
|
||||
- The first inline link-validator invocation had a Python f-string syntax error; corrected once and rerun successfully without changing the deliverable.
|
||||
- `pnpm format:check` — PASS.
|
||||
- `pnpm lint` — PASS (23 tasks successful).
|
||||
- `pnpm typecheck` — PASS (42 tasks successful).
|
||||
- `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json --noEmit` — PASS.
|
||||
- Scoped diff review — PASS: authored delta is limited to the exclusive deliverable and this scratchpad; no runtime/schema/config/dependency/CI/deployment file is changed, and `docs/native-kanban-sot/TASKS.md` has no worker worktree delta from tracking commit `9b55de0`.
|
||||
- Independent SecReview remains pending and cannot return PASS until contract authority resolves `KBN010-SI-001`.
|
||||
- Final formatting/diff/test-ID completeness review — PASS (all catalog prefixes contiguous with no duplicate IDs).
|
||||
- Remaining: scoped commit, queue guard, and push.
|
||||
@@ -18,12 +18,12 @@ Implement a transport-neutral coordination contract allowing a configured intera
|
||||
|
||||
## Design checkpoint — 2026-07-12
|
||||
|
||||
Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryMosCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task.
|
||||
Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryInteractionCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task.
|
||||
|
||||
## Progress checkpoint — 2026-07-13
|
||||
|
||||
- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
|
||||
- Implemented the gateway `MosCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context.
|
||||
- Implemented `InteractionCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
|
||||
- Implemented the gateway `InteractionCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context.
|
||||
- Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation.
|
||||
- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`.
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa
|
||||
|
||||
### Mos Coordination Boundary
|
||||
|
||||
`@mosaicstack/coord` exposes only the transport-neutral `MosCoordinationPort`
|
||||
`@mosaicstack/coord` exposes only the transport-neutral `InteractionCoordinationPort`
|
||||
verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant,
|
||||
correlation, and interaction-agent identity from authenticated context plus
|
||||
trusted configuration; callers never provide an orchestration target. It
|
||||
|
||||
@@ -6,7 +6,7 @@ This procedure is evidence-bound. It does not authorize a production cutover unt
|
||||
2. Query the normalized runtime capability surface, not a Hermes API directly. Confirm the session capabilities required for the operation are advertised.
|
||||
3. Query the transitional matrix through `RuntimeProviderService.transitionalCapabilityMatrix` (`apps/gateway/src/agent/runtime-provider-registry.service.ts`). Kanban, skills, memory, tools, and cron must remain `unsupported`; stop rather than route those operations through Hermes.
|
||||
4. Route new memory activity through the Mosaic operator-memory plugin path; there is no landed Hermes memory import.
|
||||
5. Use `MosCoordinationService` for orchestration handoff. Tess does not take Mos authority.
|
||||
5. Use `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) for orchestration handoff. The interaction agent does not take configured orchestrator authority.
|
||||
6. Record the qualification evidence and only then update an external deployment/channel binding through its separately authorized operational process.
|
||||
|
||||
No claim here authorizes bulk transcript copying, data-schema migration, or enabling an unsupported transitional capability.
|
||||
|
||||
@@ -7,5 +7,5 @@ Hermes is a reference adapter, not a Mosaic core dependency. `packages/agent/src
|
||||
| sessions, hierarchy, streaming, send/attach/terminate | `HermesRuntimeProvider` plus `hermes-runtime-provider.test.ts` | adapted |
|
||||
| Kanban, skills, memory, tools, cron | normalized matrix in `HermesRuntimeProvider.transitionalCapabilityMatrix`; each is `unsupported` and `assertTransitionalCapability` denies before a transport call | deferred / fail-closed |
|
||||
| operator memory | `packages/memory/src/operator-memory-plugin.ts`, constructed by `apps/gateway/src/memory/memory.module.ts` and session-scoped by `apps/gateway/src/agent/agent.service.ts` | native Mosaic path |
|
||||
| orchestration handoff | `apps/gateway/src/coord/mos-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | native Mosaic path |
|
||||
| orchestration handoff | `InteractionCoordinationService` in `apps/gateway/src/coord/interaction-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | native Mosaic path |
|
||||
| transcripts, profiles, preferences | no Hermes importer/schema mapping landed | no automatic migration |
|
||||
|
||||
@@ -6,5 +6,5 @@ Rollback is configuration/binding reversal, not a database rollback: no Hermes s
|
||||
2. Keep the gateway registration and core contracts unchanged unless a reviewed code rollback is required; `AgentRuntimeProviderRegistry` registration is explicit and non-replacing (`packages/agent/src/runtime-provider-registry.ts`).
|
||||
3. Do not replay an unsupported Kanban, skills, memory, tools, or cron operation. The transitional matrix is intentionally fail-closed.
|
||||
4. Preserve Mosaic audit, session, and operator-memory records under their normal scoped retention rules; do not copy them into Hermes as a rollback shortcut.
|
||||
5. For an in-flight coordination request, use the owned handoff observation/result flow in `MosCoordinationService`; do not create a second orchestrator path.
|
||||
5. For an in-flight coordination request, use the owned handoff observation/result flow in `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`); do not create a second orchestrator path.
|
||||
6. Capture the binding reversal, affected scope, correlation IDs, and reason in the approved operational record before retrying a cutover.
|
||||
|
||||
@@ -20,29 +20,29 @@ interface CoordinationScope {
|
||||
readonly requesterAgentId: string; // trusted gateway/configuration data
|
||||
}
|
||||
|
||||
interface MosHandoffRequest {
|
||||
interface HandoffRequest {
|
||||
readonly idempotencyKey: string;
|
||||
readonly summary: string;
|
||||
readonly context?: string;
|
||||
readonly missionId?: string;
|
||||
}
|
||||
|
||||
interface MosHandoffReceipt {
|
||||
interface HandoffReceipt {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly status: 'accepted' | 'queued';
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
interface MosHandoff {
|
||||
interface Handoff {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly request: MosHandoffRequest;
|
||||
readonly request: HandoffRequest;
|
||||
readonly scope: CoordinationScope;
|
||||
}
|
||||
|
||||
interface MosCoordinationPort {
|
||||
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
||||
interface InteractionCoordinationPort {
|
||||
handoff(handoff: Handoff): Promise<HandoffReceipt>;
|
||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||
}
|
||||
@@ -52,22 +52,26 @@ The port deliberately omits generic orchestrator verbs. It is tenant- and
|
||||
correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`,
|
||||
and the requester agent from trusted authentication/configuration only.
|
||||
|
||||
## HTTP routes
|
||||
|
||||
`/api/coord/interaction` is the canonical HTTP coordination prefix for handoff, observe, and result. `/api/coord/mos` remains a backward-compatible alias with the same handlers and DTOs; new integrations use the neutral canonical prefix.
|
||||
|
||||
## Enforcement point
|
||||
|
||||
`apps/gateway` owns a `MosCoordinationService` boundary that compares the
|
||||
`apps/gateway` owns an `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) boundary that compares the
|
||||
trusted configured requester/target identities and rejects all of the following
|
||||
before calling a transport: unconfigured requester, self-delegation, target
|
||||
identity drift, cross-tenant observe/result lookup, and attempts to observe or
|
||||
receive a result for a handoff outside the originating tenant. The service exposes handoff, observe,
|
||||
and result only, and delegates delivery to an injected adapter.
|
||||
|
||||
M4 ships a native in-process `InMemoryMosCoordinationPort` as the concrete,
|
||||
M4 ships a native in-process `InMemoryInteractionCoordinationPort` as the concrete,
|
||||
deterministic adapter. It preserves the immutable handoff ID, tenant, requester
|
||||
identity, and correlation ID while demonstrating the handoff → observe → result
|
||||
round trip. It is a queue/port adapter, not a Mos-side consumer.
|
||||
|
||||
A future fleet/tmux adapter is a documented M5 deployment seam and must
|
||||
implement the same `MosCoordinationPort`; no channel client or interaction
|
||||
implement the same `InteractionCoordinationPort`; no channel client or interaction
|
||||
runtime calls a transport directly.
|
||||
|
||||
## Required tests
|
||||
|
||||
@@ -33,14 +33,18 @@ Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service iden
|
||||
6. Every externally caused operation is replay-safe and correlated.
|
||||
7. Provider capability absence is a denial, not an invitation to shell around it.
|
||||
|
||||
## Existing Findings That Block Tess
|
||||
## Closed Prerequisite Findings
|
||||
|
||||
- Command executor lacks server-side enforcement for declared scopes.
|
||||
- Session list/reuse/destroy surfaces are not owner-filtered consistently.
|
||||
- MCP schemas accept caller-supplied user identity.
|
||||
- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists.
|
||||
- Chat/tool persistence lacks mandatory redaction.
|
||||
- Sessions/pending Discord output are in-memory and not restart-safe.
|
||||
- Session GC currently performs globally scoped promotion.
|
||||
The original M1 findings below are closed by landed controls and retained for audit traceability.
|
||||
|
||||
These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled.
|
||||
| Former finding | Closed evidence |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Command scope/role enforcement | `apps/gateway/src/commands/command-authorization.service.ts` and its authorization tests enforce the server-side approval boundary. |
|
||||
| Cross-owner session access | Gateway session ownership tests cover server-derived owner and tenant scope. |
|
||||
| Caller-controlled MCP identity | MCP tools derive actor and tenant from authenticated gateway context. |
|
||||
| Missing Discord ingress allowlists | `apps/gateway/src/plugin/plugin.module.ts` requires the guild, channel, and user allowlist environment values; `apps/gateway/src/plugin/discord-ingress.security.spec.ts` exercises denial and configured ingress. |
|
||||
| Missing redaction before persistence/egress | Gateway and log redaction coverage verifies sensitive content is classified before durable storage or channel delivery. |
|
||||
| In-memory-only restart safety | `packages/agent/src/durable-session.test.ts` reconstructs durable identity, inbox/outbox, checkpoints, and handoffs after simulated restart. |
|
||||
| Globally scoped session GC | `apps/gateway/src/gc/session-gc.service.spec.ts` verifies session-only collection and the absence of automatic global collection entry points. |
|
||||
|
||||
These controls remain subject to the runtime's independent review and release qualification gates.
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
DurableSessionCoordinator,
|
||||
InMemoryDurableSessionStore,
|
||||
type DurableSessionIdentity,
|
||||
} from './tess-durable-session.js';
|
||||
} from './durable-session.js';
|
||||
|
||||
const IDENTITY: DurableSessionIdentity = {
|
||||
agentName: 'Nova',
|
||||
@@ -102,7 +102,7 @@ export interface DurableSessionStore {
|
||||
|
||||
export class DurableSessionNotFoundError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Durable Tess session not found: ${sessionId}`);
|
||||
super(`Durable session not found: ${sessionId}`);
|
||||
this.name = 'DurableSessionNotFoundError';
|
||||
}
|
||||
}
|
||||
@@ -212,11 +212,11 @@ export class DurableSessionCoordinator {
|
||||
|
||||
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
|
||||
const handoff = await this.store.findHandoff(handoffId);
|
||||
if (!handoff) throw new Error(`Durable Tess handoff not found: ${handoffId}`);
|
||||
if (!handoff) throw new Error(`Durable handoff not found: ${handoffId}`);
|
||||
const snapshot = await this.snapshot(handoff.sessionId);
|
||||
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
|
||||
if (!checkpoint) {
|
||||
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${handoff.checkpointId}`);
|
||||
throw new Error(`Durable handoff checkpoint is unavailable: ${handoff.checkpointId}`);
|
||||
}
|
||||
return { identity: snapshot.identity, checkpoint, handoff };
|
||||
}
|
||||
@@ -257,7 +257,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
||||
const existing = this.sessions.get(identity.sessionId);
|
||||
if (existing) {
|
||||
if (!sameEnrollmentScope(existing.identity, identity)) {
|
||||
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
||||
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
||||
}
|
||||
existing.identity.providerId = identity.providerId;
|
||||
existing.identity.runtimeSessionId = identity.runtimeSessionId;
|
||||
@@ -290,7 +290,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
||||
const existing = state.inbox.get(input.idempotencyKey);
|
||||
if (existing) {
|
||||
if (!sameInbox(existing, input)) {
|
||||
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: existing.status };
|
||||
}
|
||||
@@ -323,7 +323,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
||||
const existing = state.outbox.get(input.idempotencyKey);
|
||||
if (existing) {
|
||||
if (!sameOutbox(existing, input)) {
|
||||
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: existing.status };
|
||||
}
|
||||
@@ -364,7 +364,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
||||
const checkpoints = this.require(input.sessionId).checkpoints;
|
||||
const existing = checkpoints.get(input.checkpointId);
|
||||
if (existing && !sameCheckpoint(existing, input)) {
|
||||
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
||||
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
||||
}
|
||||
if (!existing) checkpoints.set(input.checkpointId, { ...input });
|
||||
}
|
||||
@@ -377,11 +377,11 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||
const state = this.require(input.sessionId);
|
||||
if (!state.checkpoints.has(input.checkpointId)) {
|
||||
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||
}
|
||||
const existing = state.handoffs.get(input.handoffId);
|
||||
if (existing && !sameHandoff(existing, input)) {
|
||||
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
||||
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
||||
}
|
||||
if (!existing) state.handoffs.set(input.handoffId, { ...input });
|
||||
}
|
||||
@@ -413,7 +413,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
||||
kind: string,
|
||||
): T {
|
||||
const entry = records.get(idempotencyKey);
|
||||
if (!entry) throw new Error(`Durable Tess ${kind} entry not found: ${idempotencyKey}`);
|
||||
if (!entry) throw new Error(`Durable ${kind} entry not found: ${idempotencyKey}`);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,4 @@ export * from './runtime-provider-registry.js';
|
||||
export * from './tmux-fleet-runtime-provider.js';
|
||||
export * from './hermes-runtime-provider.js';
|
||||
export * from './matrix-native-runtime-provider.js';
|
||||
export * from './tess-durable-session.js';
|
||||
export * from './durable-session.js';
|
||||
|
||||
@@ -117,7 +117,7 @@ class DenyMatrixWriteAuthority implements MatrixWriteAuthority {
|
||||
async assertAuthorized(): Promise<void> {
|
||||
throw new MatrixRuntimeProviderError(
|
||||
'forbidden',
|
||||
'Matrix runtime writes require Mos authority',
|
||||
'Matrix runtime writes require orchestrator authority',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
||||
expect(fleet.terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unverified target before consulting Mos write authority', async (): Promise<void> => {
|
||||
it('rejects an unverified target before consulting orchestrator write authority', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
|
||||
throw new Error('target identity mismatch');
|
||||
@@ -220,7 +220,20 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
||||
expect(fleet.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes an exact session ID to the fleet transport only through authorized Mos writes', async (): Promise<void> => {
|
||||
it('uses the role-neutral interaction source label by default', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const writeAuthority: FleetWriteAuthority = {
|
||||
canWrite: vi.fn(async (): Promise<boolean> => true),
|
||||
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
|
||||
};
|
||||
const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority });
|
||||
|
||||
await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope);
|
||||
|
||||
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'interaction');
|
||||
});
|
||||
|
||||
it('passes an exact session ID to the fleet transport only through orchestrator-authorized writes', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const writeAuthority: FleetWriteAuthority = {
|
||||
canWrite: vi.fn(async (): Promise<boolean> => true),
|
||||
@@ -228,7 +241,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
||||
};
|
||||
const provider = new TmuxFleetRuntimeProvider({
|
||||
transport: fleet,
|
||||
sourceLabel: 'tess',
|
||||
sourceLabel: 'operator',
|
||||
writeAuthority,
|
||||
});
|
||||
|
||||
@@ -246,7 +259,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
||||
scope,
|
||||
approvalRef: 'approval-1',
|
||||
});
|
||||
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'tess');
|
||||
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator');
|
||||
expect(fleet.terminate).toHaveBeenCalledWith('coder0');
|
||||
});
|
||||
|
||||
|
||||
@@ -64,14 +64,14 @@ export interface FleetWriteAuthorization {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mos is the only authority that may permit Tess write/control requests to a
|
||||
* The orchestrator is the only authority that may permit interaction-plane write/control requests to a
|
||||
* fleet peer. Gateway records the request and denial/success around provider
|
||||
* invocation; the default authority prevents direct Tess writes by design.
|
||||
* invocation; the default authority prevents direct interaction-plane writes by design.
|
||||
*/
|
||||
export interface FleetWriteAuthority {
|
||||
/** Non-consuming preflight used before probing the fleet transport. */
|
||||
canWrite(authorization: FleetWriteAuthorization): Promise<boolean>;
|
||||
/** Final exact-target authorization; may consume a Mos grant. */
|
||||
/** Final exact-target authorization; may consume an orchestrator grant. */
|
||||
assertAuthorized(authorization: FleetWriteAuthorization): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority {
|
||||
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
|
||||
throw new FleetRuntimeProviderError(
|
||||
'forbidden',
|
||||
'Fleet writes require an explicit Mos authority decision',
|
||||
'Fleet writes require an explicit orchestrator authority decision',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority {
|
||||
/**
|
||||
* A capability-limited provider for rostered local fleet peers. It never
|
||||
* permits raw tmux socket/target selection, interactive control attach, or
|
||||
* direct Tess writes; all side effects pass through exact transport checks.
|
||||
* direct interaction-plane writes; all side effects pass through exact transport checks.
|
||||
*/
|
||||
export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
||||
readonly id = FLEET_PROVIDER_ID;
|
||||
@@ -139,7 +139,7 @@ export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
||||
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
|
||||
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
|
||||
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
|
||||
this.sourceLabel = options.sourceLabel ?? 'tess';
|
||||
this.sourceLabel = options.sourceLabel ?? 'interaction';
|
||||
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
InMemoryMosCoordinationPort,
|
||||
MosCoordinationClient,
|
||||
InMemoryInteractionCoordinationPort,
|
||||
InteractionCoordinationClient,
|
||||
type CoordinationScope,
|
||||
type MosCoordinationAuthorityError,
|
||||
type MosCoordinationPort,
|
||||
type InteractionCoordinationAuthorityError,
|
||||
type InteractionCoordinationPort,
|
||||
} from '../index.js';
|
||||
|
||||
const scope: CoordinationScope = {
|
||||
@@ -15,19 +15,19 @@ const scope: CoordinationScope = {
|
||||
};
|
||||
|
||||
function client(
|
||||
port: MosCoordinationPort,
|
||||
port: InteractionCoordinationPort,
|
||||
handoffIdFactory: () => string = (): string => 'handoff-1',
|
||||
): MosCoordinationClient {
|
||||
return new MosCoordinationClient(
|
||||
): InteractionCoordinationClient {
|
||||
return new InteractionCoordinationClient(
|
||||
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
|
||||
port,
|
||||
handoffIdFactory,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MosCoordinationClient', (): void => {
|
||||
describe('InteractionCoordinationClient', (): void => {
|
||||
it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const coordination = client(adapter);
|
||||
|
||||
await expect(
|
||||
@@ -42,8 +42,8 @@ describe('MosCoordinationClient', (): void => {
|
||||
correlationId: 'corr-1',
|
||||
});
|
||||
|
||||
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
||||
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
||||
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
|
||||
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
||||
|
||||
await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({
|
||||
status: 'completed',
|
||||
@@ -59,7 +59,7 @@ describe('MosCoordinationClient', (): void => {
|
||||
targetAgentId: 'Conductor',
|
||||
status: 'completed',
|
||||
correlationId: 'corr-1',
|
||||
summary: 'Merged by Mos',
|
||||
summary: 'Merged by orchestrator',
|
||||
});
|
||||
|
||||
expect(coordination).not.toHaveProperty('dispatch');
|
||||
@@ -69,8 +69,8 @@ describe('MosCoordinationClient', (): void => {
|
||||
expect(coordination).not.toHaveProperty('cancel');
|
||||
});
|
||||
|
||||
it('fails closed before delivery when an unconfigured agent requests Mos work', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
it('fails closed before delivery when an unconfigured agent requests orchestrator work', async (): Promise<void> => {
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
|
||||
await expect(
|
||||
client(adapter).handoff(
|
||||
@@ -79,31 +79,31 @@ describe('MosCoordinationClient', (): void => {
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'requester_forbidden',
|
||||
} satisfies Partial<MosCoordinationAuthorityError>);
|
||||
} satisfies Partial<InteractionCoordinationAuthorityError>);
|
||||
});
|
||||
|
||||
it('rejects self-delegation configuration before constructing a client', (): void => {
|
||||
expect(
|
||||
(): MosCoordinationClient =>
|
||||
new MosCoordinationClient(
|
||||
(): InteractionCoordinationClient =>
|
||||
new InteractionCoordinationClient(
|
||||
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||
new InMemoryMosCoordinationPort(),
|
||||
new InMemoryInteractionCoordinationPort(),
|
||||
),
|
||||
).toThrow('Interaction and orchestration identities must differ');
|
||||
});
|
||||
|
||||
it('rejects whitespace-equivalent self-delegation identities', (): void => {
|
||||
expect(
|
||||
(): MosCoordinationClient =>
|
||||
new MosCoordinationClient(
|
||||
(): InteractionCoordinationClient =>
|
||||
new InteractionCoordinationClient(
|
||||
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
|
||||
new InMemoryMosCoordinationPort(),
|
||||
new InMemoryInteractionCoordinationPort(),
|
||||
),
|
||||
).toThrow('Interaction and orchestration identities must differ');
|
||||
});
|
||||
|
||||
it('does not expose another tenant handoff to observe or result', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const adapter = new InMemoryInteractionCoordinationPort();
|
||||
const coordination = client(adapter);
|
||||
await coordination.handoff(
|
||||
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||
@@ -120,7 +120,7 @@ describe('MosCoordinationClient', (): void => {
|
||||
});
|
||||
|
||||
it('bounds native handoff retention by evicting the oldest handoff', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort({ maxHandoffs: 1 });
|
||||
const adapter = new InMemoryInteractionCoordinationPort({ maxHandoffs: 1 });
|
||||
const first = client(adapter, (): string => 'handoff-1');
|
||||
const second = client(adapter, (): string => 'handoff-2');
|
||||
await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope);
|
||||
@@ -131,7 +131,7 @@ describe('MosCoordinationClient', (): void => {
|
||||
});
|
||||
|
||||
it('fails closed when a transport reports target drift', async (): Promise<void> => {
|
||||
const adapter: MosCoordinationPort = {
|
||||
const adapter: InteractionCoordinationPort = {
|
||||
handoff: vi.fn(async () => ({
|
||||
handoffId: 'handoff-1',
|
||||
targetAgentId: 'Unexpected',
|
||||
@@ -149,6 +149,6 @@ describe('MosCoordinationClient', (): void => {
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'target_drift',
|
||||
} satisfies Partial<MosCoordinationAuthorityError>);
|
||||
} satisfies Partial<InteractionCoordinationAuthorityError>);
|
||||
});
|
||||
});
|
||||
@@ -2,25 +2,25 @@ import {
|
||||
type CoordinationObservation,
|
||||
type CoordinationResult,
|
||||
type CoordinationScope,
|
||||
type MosCoordinationActivity,
|
||||
type MosCoordinationPort,
|
||||
type MosHandoff,
|
||||
type MosHandoffReceipt,
|
||||
type MosHandoffStatus,
|
||||
} from './mos-coordination.js';
|
||||
type InteractionCoordinationActivity,
|
||||
type InteractionCoordinationPort,
|
||||
type Handoff,
|
||||
type HandoffReceipt,
|
||||
type HandoffStatus,
|
||||
} from './interaction-coordination.js';
|
||||
|
||||
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
|
||||
const DEFAULT_MAX_HANDOFFS = 1_000;
|
||||
|
||||
interface StoredHandoff {
|
||||
readonly handoff: MosHandoff;
|
||||
status: MosHandoffStatus;
|
||||
readonly activity: MosCoordinationActivity[];
|
||||
readonly handoff: Handoff;
|
||||
status: HandoffStatus;
|
||||
readonly activity: InteractionCoordinationActivity[];
|
||||
readonly expiresAt: number;
|
||||
result?: CoordinationResult;
|
||||
}
|
||||
|
||||
export interface InMemoryMosCoordinationPortOptions {
|
||||
export interface InMemoryInteractionCoordinationPortOptions {
|
||||
now?: () => Date;
|
||||
handoffTtlMs?: number;
|
||||
maxHandoffs?: number;
|
||||
@@ -29,21 +29,21 @@ export interface InMemoryMosCoordinationPortOptions {
|
||||
/**
|
||||
* Native deterministic queue/port adapter for the coordination boundary.
|
||||
* It intentionally has no fleet/tmux dependency. A future deployment adapter
|
||||
* implements MosCoordinationPort without changing interaction-plane callers.
|
||||
* implements InteractionCoordinationPort without changing interaction-plane callers.
|
||||
*/
|
||||
export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
||||
export class InMemoryInteractionCoordinationPort implements InteractionCoordinationPort {
|
||||
private readonly handoffs = new Map<string, StoredHandoff>();
|
||||
private readonly now: () => Date;
|
||||
private readonly handoffTtlMs: number;
|
||||
private readonly maxHandoffs: number;
|
||||
|
||||
constructor(options: InMemoryMosCoordinationPortOptions = {}) {
|
||||
constructor(options: InMemoryInteractionCoordinationPortOptions = {}) {
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
|
||||
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
|
||||
}
|
||||
|
||||
async handoff(handoff: MosHandoff): Promise<MosHandoffReceipt> {
|
||||
async handoff(handoff: Handoff): Promise<HandoffReceipt> {
|
||||
this.pruneExpiredHandoffs();
|
||||
const existing = this.handoffs.get(handoff.handoffId);
|
||||
if (existing !== undefined) {
|
||||
@@ -88,14 +88,14 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
||||
}
|
||||
|
||||
/** Host-side progression seam; interaction clients never receive this capability. */
|
||||
recordActivity(handoffId: string, status: MosHandoffStatus, summary: string): void {
|
||||
recordActivity(handoffId: string, status: HandoffStatus, summary: string): void {
|
||||
this.pruneExpiredHandoffs();
|
||||
const stored = this.requireHandoff(handoffId);
|
||||
stored.status = status;
|
||||
stored.activity.push(activity(status, summary, this.now));
|
||||
}
|
||||
|
||||
/** Host-side result seam for deterministic qualification; not a Mos consumer. */
|
||||
/** Host-side result seam for deterministic qualification; not an orchestrator consumer. */
|
||||
recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void {
|
||||
this.pruneExpiredHandoffs();
|
||||
const stored = this.requireHandoff(handoffId);
|
||||
@@ -110,7 +110,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
||||
};
|
||||
}
|
||||
|
||||
private receipt(handoff: MosHandoff, status: MosHandoffStatus): MosHandoffReceipt {
|
||||
private receipt(handoff: Handoff, status: HandoffStatus): HandoffReceipt {
|
||||
return {
|
||||
handoffId: handoff.handoffId,
|
||||
targetAgentId: handoff.targetAgentId,
|
||||
@@ -141,7 +141,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
||||
stored.handoff.scope.actorId !== scope.actorId ||
|
||||
stored.handoff.scope.requesterAgentId !== scope.requesterAgentId
|
||||
) {
|
||||
throw new InMemoryMosCoordinationError('forbidden', 'Handoff scope does not match');
|
||||
throw new InMemoryInteractionCoordinationError('forbidden', 'Handoff scope does not match');
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
@@ -149,12 +149,12 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
||||
private requireHandoff(handoffId: string): StoredHandoff {
|
||||
const stored = this.handoffs.get(handoffId);
|
||||
if (stored === undefined) {
|
||||
throw new InMemoryMosCoordinationError('not_found', 'Handoff was not found');
|
||||
throw new InMemoryInteractionCoordinationError('not_found', 'Handoff was not found');
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
private assertSameHandoff(existing: MosHandoff, incoming: MosHandoff): void {
|
||||
private assertSameHandoff(existing: Handoff, incoming: Handoff): void {
|
||||
if (
|
||||
existing.targetAgentId !== incoming.targetAgentId ||
|
||||
existing.request.idempotencyKey !== incoming.request.idempotencyKey ||
|
||||
@@ -166,7 +166,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
||||
existing.scope.correlationId !== incoming.scope.correlationId ||
|
||||
existing.scope.requesterAgentId !== incoming.scope.requesterAgentId
|
||||
) {
|
||||
throw new InMemoryMosCoordinationError(
|
||||
throw new InMemoryInteractionCoordinationError(
|
||||
'conflict',
|
||||
'Handoff ID is already bound to different immutable input',
|
||||
);
|
||||
@@ -174,31 +174,31 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
||||
}
|
||||
}
|
||||
|
||||
export type InMemoryMosCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
|
||||
export type InMemoryInteractionCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
|
||||
|
||||
export class InMemoryMosCoordinationError extends Error {
|
||||
export class InMemoryInteractionCoordinationError extends Error {
|
||||
constructor(
|
||||
readonly code: InMemoryMosCoordinationErrorCode,
|
||||
readonly code: InMemoryInteractionCoordinationErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = InMemoryMosCoordinationError.name;
|
||||
this.name = InMemoryInteractionCoordinationError.name;
|
||||
}
|
||||
}
|
||||
|
||||
function activity(
|
||||
status: MosHandoffStatus,
|
||||
status: HandoffStatus,
|
||||
summary: string,
|
||||
now: () => Date,
|
||||
): MosCoordinationActivity {
|
||||
): InteractionCoordinationActivity {
|
||||
return { occurredAt: now().toISOString(), status, summary };
|
||||
}
|
||||
|
||||
function copyActivity(entry: MosCoordinationActivity): MosCoordinationActivity {
|
||||
function copyActivity(entry: InteractionCoordinationActivity): InteractionCoordinationActivity {
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
function snapshotHandoff(handoff: MosHandoff): MosHandoff {
|
||||
function snapshotHandoff(handoff: Handoff): Handoff {
|
||||
return Object.freeze({
|
||||
handoffId: handoff.handoffId,
|
||||
targetAgentId: handoff.targetAgentId,
|
||||
@@ -3,22 +3,25 @@ export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.j
|
||||
export { runTask, resumeTask } from './runner.js';
|
||||
export { getMissionStatus, getTaskStatus } from './status.js';
|
||||
export {
|
||||
InMemoryMosCoordinationError,
|
||||
InMemoryMosCoordinationPort,
|
||||
} from './in-memory-mos-coordination-port.js';
|
||||
export { MosCoordinationAuthorityError, MosCoordinationClient } from './mos-coordination.js';
|
||||
InMemoryInteractionCoordinationError,
|
||||
InMemoryInteractionCoordinationPort,
|
||||
} from './in-memory-interaction-coordination-port.js';
|
||||
export {
|
||||
InteractionCoordinationAuthorityError,
|
||||
InteractionCoordinationClient,
|
||||
} from './interaction-coordination.js';
|
||||
export type {
|
||||
CoordinationObservation,
|
||||
CoordinationResult,
|
||||
CoordinationScope,
|
||||
MosCoordinationActivity,
|
||||
MosCoordinationIdentity,
|
||||
MosCoordinationPort,
|
||||
MosHandoff,
|
||||
MosHandoffReceipt,
|
||||
MosHandoffRequest,
|
||||
MosHandoffStatus,
|
||||
} from './mos-coordination.js';
|
||||
InteractionCoordinationActivity,
|
||||
InteractionCoordinationIdentity,
|
||||
InteractionCoordinationPort,
|
||||
Handoff,
|
||||
HandoffReceipt,
|
||||
HandoffRequest,
|
||||
HandoffStatus,
|
||||
} from './interaction-coordination.js';
|
||||
export type {
|
||||
CreateMissionOptions,
|
||||
Mission,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type MosHandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
|
||||
export type HandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface CoordinationScope {
|
||||
readonly actorId: string;
|
||||
@@ -8,44 +8,44 @@ export interface CoordinationScope {
|
||||
readonly requesterAgentId: string;
|
||||
}
|
||||
|
||||
export interface MosCoordinationIdentity {
|
||||
export interface InteractionCoordinationIdentity {
|
||||
readonly interactionAgentId: string;
|
||||
readonly orchestrationAgentId: string;
|
||||
}
|
||||
|
||||
export interface MosHandoffRequest {
|
||||
export interface HandoffRequest {
|
||||
readonly idempotencyKey: string;
|
||||
readonly summary: string;
|
||||
readonly context?: string;
|
||||
readonly missionId?: string;
|
||||
}
|
||||
|
||||
export interface MosHandoff {
|
||||
export interface Handoff {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly request: MosHandoffRequest;
|
||||
readonly request: HandoffRequest;
|
||||
readonly scope: CoordinationScope;
|
||||
}
|
||||
|
||||
export interface MosHandoffReceipt {
|
||||
export interface HandoffReceipt {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly status: 'queued' | 'accepted';
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface MosCoordinationActivity {
|
||||
export interface InteractionCoordinationActivity {
|
||||
readonly occurredAt: string;
|
||||
readonly status: MosHandoffStatus;
|
||||
readonly status: HandoffStatus;
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
export interface CoordinationObservation {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly status: MosHandoffStatus;
|
||||
readonly status: HandoffStatus;
|
||||
readonly correlationId: string;
|
||||
readonly activity: readonly MosCoordinationActivity[];
|
||||
readonly activity: readonly InteractionCoordinationActivity[];
|
||||
}
|
||||
|
||||
export interface CoordinationResult {
|
||||
@@ -61,25 +61,25 @@ export interface CoordinationResult {
|
||||
* its progress/result, but it cannot issue worker, review, merge, or other
|
||||
* general orchestration commands.
|
||||
*/
|
||||
export interface MosCoordinationPort {
|
||||
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
||||
export interface InteractionCoordinationPort {
|
||||
handoff(handoff: Handoff): Promise<HandoffReceipt>;
|
||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||
}
|
||||
|
||||
export type MosCoordinationAuthorityErrorCode =
|
||||
export type InteractionCoordinationAuthorityErrorCode =
|
||||
| 'invalid_identity'
|
||||
| 'requester_forbidden'
|
||||
| 'target_drift'
|
||||
| 'correlation_drift';
|
||||
|
||||
export class MosCoordinationAuthorityError extends Error {
|
||||
export class InteractionCoordinationAuthorityError extends Error {
|
||||
constructor(
|
||||
readonly code: MosCoordinationAuthorityErrorCode,
|
||||
readonly code: InteractionCoordinationAuthorityErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = MosCoordinationAuthorityError.name;
|
||||
this.name = InteractionCoordinationAuthorityError.name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,20 +87,20 @@ export class MosCoordinationAuthorityError extends Error {
|
||||
* Enforces the interaction-to-orchestration authority boundary before a
|
||||
* transport is reached. Identity names remain configuration data.
|
||||
*/
|
||||
export class MosCoordinationClient {
|
||||
private readonly identity: MosCoordinationIdentity;
|
||||
export class InteractionCoordinationClient {
|
||||
private readonly identity: InteractionCoordinationIdentity;
|
||||
|
||||
constructor(
|
||||
identity: MosCoordinationIdentity,
|
||||
private readonly port: MosCoordinationPort,
|
||||
identity: InteractionCoordinationIdentity,
|
||||
private readonly port: InteractionCoordinationPort,
|
||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||
) {
|
||||
this.identity = normalizeIdentity(identity);
|
||||
}
|
||||
|
||||
async handoff(request: MosHandoffRequest, scope: CoordinationScope): Promise<MosHandoffReceipt> {
|
||||
async handoff(request: HandoffRequest, scope: CoordinationScope): Promise<HandoffReceipt> {
|
||||
this.assertRequester(scope);
|
||||
const handoff: MosHandoff = {
|
||||
const handoff: Handoff = {
|
||||
handoffId: this.handoffIdFactory(),
|
||||
targetAgentId: this.identity.orchestrationAgentId,
|
||||
request: snapshotRequest(request),
|
||||
@@ -111,15 +111,15 @@ export class MosCoordinationClient {
|
||||
receipt.handoffId !== handoff.handoffId ||
|
||||
receipt.targetAgentId !== handoff.targetAgentId
|
||||
) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'target_drift',
|
||||
'Mos coordination transport returned a mismatched handoff target',
|
||||
'Interaction coordination transport returned a mismatched handoff target',
|
||||
);
|
||||
}
|
||||
if (receipt.correlationId !== handoff.scope.correlationId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'correlation_drift',
|
||||
'Mos coordination transport returned a mismatched correlation ID',
|
||||
'Interaction coordination transport returned a mismatched correlation ID',
|
||||
);
|
||||
}
|
||||
return receipt;
|
||||
@@ -137,7 +137,7 @@ export class MosCoordinationClient {
|
||||
|
||||
private assertRequester(scope: CoordinationScope): void {
|
||||
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'requester_forbidden',
|
||||
'Requester is not the configured interaction agent',
|
||||
);
|
||||
@@ -149,15 +149,15 @@ export class MosCoordinationClient {
|
||||
scope: CoordinationScope,
|
||||
): CoordinationObservation {
|
||||
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'target_drift',
|
||||
'Mos coordination transport returned an unexpected observation target',
|
||||
'Interaction coordination transport returned an unexpected observation target',
|
||||
);
|
||||
}
|
||||
if (observation.correlationId !== scope.correlationId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'correlation_drift',
|
||||
'Mos coordination transport returned a mismatched observation correlation ID',
|
||||
'Interaction coordination transport returned a mismatched observation correlation ID',
|
||||
);
|
||||
}
|
||||
return observation;
|
||||
@@ -165,32 +165,34 @@ export class MosCoordinationClient {
|
||||
|
||||
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
|
||||
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'target_drift',
|
||||
'Mos coordination transport returned an unexpected result target',
|
||||
'Interaction coordination transport returned an unexpected result target',
|
||||
);
|
||||
}
|
||||
if (result.correlationId !== scope.correlationId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'correlation_drift',
|
||||
'Mos coordination transport returned a mismatched result correlation ID',
|
||||
'Interaction coordination transport returned a mismatched result correlation ID',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationIdentity {
|
||||
function normalizeIdentity(
|
||||
identity: InteractionCoordinationIdentity,
|
||||
): InteractionCoordinationIdentity {
|
||||
const interactionAgentId = identity.interactionAgentId.trim();
|
||||
const orchestrationAgentId = identity.orchestrationAgentId.trim();
|
||||
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'invalid_identity',
|
||||
'Interaction and orchestration identities are required',
|
||||
);
|
||||
}
|
||||
if (interactionAgentId === orchestrationAgentId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'invalid_identity',
|
||||
'Interaction and orchestration identities must differ',
|
||||
);
|
||||
@@ -198,7 +200,7 @@ function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationId
|
||||
return Object.freeze({ interactionAgentId, orchestrationAgentId });
|
||||
}
|
||||
|
||||
function snapshotRequest(request: MosHandoffRequest): MosHandoffRequest {
|
||||
function snapshotRequest(request: HandoffRequest): HandoffRequest {
|
||||
return Object.freeze({ ...request });
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
The **operator-interaction** role is the authorized human interaction plane for
|
||||
Mosaic. It presents runtime and fleet state, mediates approved actions, and
|
||||
hands coding or general orchestration work to Mos.
|
||||
hands coding or general orchestration work to the orchestrator.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- It does not claim Mos-owned coding or general orchestration work.
|
||||
- It does not claim orchestrator-owned coding or general orchestration work.
|
||||
- It exposes only the configured, observable tool policy.
|
||||
- It does not receive or surface credentials in its effective policy.
|
||||
|
||||
@@ -75,6 +75,14 @@
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||
},
|
||||
"alias": {
|
||||
"description": "Optional operator-defined display name for the agent.",
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"description": "Optional agent runtime provider identifier such as openai-codex.",
|
||||
"type": "string"
|
||||
},
|
||||
"runtime": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -189,6 +189,36 @@ describe('fleet roster parsing', () => {
|
||||
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
||||
});
|
||||
|
||||
it('accepts optional agent alias and provider metadata without requiring them', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.yaml');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: interaction',
|
||||
' alias: Friday',
|
||||
' provider: openai-codex',
|
||||
' runtime: pi',
|
||||
' - name: worker0',
|
||||
' runtime: codex',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
|
||||
expect(getRosterAgent(roster, 'interaction')).toMatchObject({
|
||||
name: 'interaction',
|
||||
alias: 'Friday',
|
||||
provider: 'openai-codex',
|
||||
});
|
||||
expect(getRosterAgent(roster, 'worker0')).toMatchObject({ name: 'worker0' });
|
||||
expect(getRosterAgent(roster, 'worker0').alias).toBeUndefined();
|
||||
expect(getRosterAgent(roster, 'worker0').provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('socketArgs: named socket → -L <name>; empty → no -L (default socket)', () => {
|
||||
expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']);
|
||||
expect(socketArgs('')).toEqual([]);
|
||||
@@ -1872,7 +1902,61 @@ describe('fleet ps — tenant and host', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet ps — JSON output shape (FR-6)', () => {
|
||||
describe('fleet ps — aliases and JSON output shape (FR-6)', () => {
|
||||
it('shows an agent alias in table output and falls back to name when absent', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: interaction',
|
||||
' alias: Friday',
|
||||
' runtime: pi',
|
||||
' - name: worker0',
|
||||
' runtime: codex',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
const fullArgs = [command, ...args].join(' ');
|
||||
if (fullArgs.includes('list-sessions')) {
|
||||
return { stdout: 'interaction\nworker0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return {
|
||||
stdout: fullArgs.includes('systemctl')
|
||||
? 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n'
|
||||
: '12345 pi 0 0 0 0\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
const origLog = console.log;
|
||||
console.log = (msg: string) => {
|
||||
lines.push(msg);
|
||||
};
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('Friday');
|
||||
expect(output).toContain('worker0');
|
||||
});
|
||||
|
||||
it('produces --json records including tenant_id and host for each agent', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
|
||||
@@ -81,6 +81,8 @@ interface RawFleetRoster {
|
||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||
agents?: Array<{
|
||||
name?: unknown;
|
||||
alias?: unknown;
|
||||
provider?: unknown;
|
||||
runtime?: unknown;
|
||||
class?: unknown;
|
||||
working_directory?: unknown;
|
||||
@@ -102,6 +104,8 @@ interface RawFleetRoster {
|
||||
|
||||
export interface FleetAgent {
|
||||
name: string;
|
||||
alias?: string;
|
||||
provider?: string;
|
||||
runtime: string;
|
||||
className: string;
|
||||
workingDirectory?: string;
|
||||
@@ -1074,6 +1078,7 @@ export interface HeartbeatInfo {
|
||||
|
||||
export interface AgentPsRow {
|
||||
name: string;
|
||||
alias?: string;
|
||||
tenant_id: string;
|
||||
host: string;
|
||||
runtime: string;
|
||||
@@ -1755,6 +1760,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
|
||||
rows.push({
|
||||
name: agent.name,
|
||||
alias: agent.alias,
|
||||
tenant_id,
|
||||
host,
|
||||
runtime: agent.runtime,
|
||||
@@ -1888,7 +1894,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
|
||||
console.log(
|
||||
[
|
||||
row.name.padEnd(18),
|
||||
(row.alias ?? row.name).padEnd(18),
|
||||
row.tenant_id.padEnd(12),
|
||||
row.host.padEnd(12),
|
||||
row.runtime.padEnd(10),
|
||||
@@ -2494,6 +2500,8 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
||||
assertObject(raw, 'Fleet roster agent');
|
||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||
'name',
|
||||
'alias',
|
||||
'provider',
|
||||
'runtime',
|
||||
'class',
|
||||
'working_directory',
|
||||
@@ -2525,6 +2533,8 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
||||
}
|
||||
return {
|
||||
name,
|
||||
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
||||
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
||||
runtime,
|
||||
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||
workingDirectory: optionalString(
|
||||
@@ -2827,6 +2837,12 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
||||
runtime: agent.runtime,
|
||||
class: agent.className,
|
||||
};
|
||||
if (agent.alias !== undefined) {
|
||||
raw['alias'] = agent.alias;
|
||||
}
|
||||
if (agent.provider !== undefined) {
|
||||
raw['provider'] = agent.provider;
|
||||
}
|
||||
if (agent.workingDirectory !== undefined) {
|
||||
raw['working_directory'] = agent.workingDirectory;
|
||||
}
|
||||
|
||||
@@ -414,7 +414,7 @@ function readFleetToolPolicyBlock(policy: string | undefined): string {
|
||||
'',
|
||||
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
||||
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
|
||||
'Delegate Mos-owned work through the authorized handoff boundary.',
|
||||
'Delegate orchestrator-owned work through the authorized handoff boundary.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
attachInteractionSession,
|
||||
sendInteractionMessage,
|
||||
stopInteractionSession,
|
||||
} from './gateway-api.js';
|
||||
|
||||
const gateway = 'https://gateway.example.test';
|
||||
const cookie = 'session=test';
|
||||
const base = { agentName: 'Nova', correlationId: 'corr-1', sessionId: 'session-1' };
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
/** Unit coverage: the TUI preserves a non-success gateway response in its CLI error. */
|
||||
describe('interaction gateway error mapping', (): void => {
|
||||
it.each([
|
||||
{
|
||||
name: 'attach unauthorized',
|
||||
response: { status: 401, message: 'Invalid or expired session' },
|
||||
invoke: (): Promise<unknown> => attachInteractionSession(gateway, cookie, base),
|
||||
expected:
|
||||
'Failed to attach interaction session (401): {"message":"Invalid or expired session"}',
|
||||
},
|
||||
{
|
||||
name: 'send invalid request',
|
||||
response: { status: 403, message: 'Content and idempotency key are required' },
|
||||
invoke: (): Promise<unknown> =>
|
||||
sendInteractionMessage(gateway, cookie, {
|
||||
...base,
|
||||
content: 'hello',
|
||||
idempotencyKey: 'message-1',
|
||||
}),
|
||||
expected:
|
||||
'Failed to send interaction message (403): {"message":"Content and idempotency key are required"}',
|
||||
},
|
||||
{
|
||||
name: 'stop forbidden',
|
||||
response: { status: 403, message: 'Runtime termination approval denied' },
|
||||
invoke: (): Promise<unknown> =>
|
||||
stopInteractionSession(gateway, cookie, { ...base, approvalRef: 'approval-1' }),
|
||||
expected:
|
||||
'Failed to stop interaction session (403): {"message":"Runtime termination approval denied"}',
|
||||
},
|
||||
])(
|
||||
'$name preserves the typed gateway denial in the CLI error',
|
||||
async ({ response, invoke, expected }) => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ message: response.message }), {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(invoke()).rejects.toThrow(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user