Compare commits
4 Commits
17932c3fb1
...
docs/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c3f28ae5a | ||
| 49e8a54105 | |||
| d077183554 | |||
| 405984af5a |
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
import { InteractionController } from '../../agent/interaction.controller.js';
|
import { InteractionController } from '../../agent/interaction.controller.js';
|
||||||
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.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 { ChatGateway } from '../../chat/chat.gateway.js';
|
||||||
import { CommandAuthorizationService } from '../../commands/command-authorization.service.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(() => {
|
afterEach(() => {
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
const value = priorEnv.get(key);
|
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,
|
new InMemoryDurableSessionStore() as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { SessionsController } from './sessions.controller.js';
|
|||||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||||
import { InteractionController } from './interaction.controller.js';
|
import { InteractionController } from './interaction.controller.js';
|
||||||
import { RoutingController } from './routing/routing.controller.js';
|
import { RoutingController } from './routing/routing.controller.js';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
@@ -44,8 +44,8 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
TessDurableSessionRepository,
|
DurableSessionRepository,
|
||||||
TessDurableSessionService,
|
DurableSessionService,
|
||||||
{
|
{
|
||||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
useFactory: createGatewayRuntimeProviderRegistry,
|
useFactory: createGatewayRuntimeProviderRegistry,
|
||||||
@@ -76,7 +76,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
TessDurableSessionService,
|
DurableSessionService,
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
/** Server-side request for a replay-safe provider message. */
|
/** Server-side request for a replay-safe provider message. */
|
||||||
export interface TessProviderOutboxDto {
|
export interface ProviderOutboxDto {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
correlationId: string;
|
correlationId: string;
|
||||||
@@ -6,8 +6,8 @@ import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/
|
|||||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
|
|
||||||
const IDENTITY: DurableSessionIdentity = {
|
const IDENTITY: DurableSessionIdentity = {
|
||||||
agentName: 'Nova',
|
agentName: 'Nova',
|
||||||
@@ -18,7 +18,7 @@ const IDENTITY: DurableSessionIdentity = {
|
|||||||
runtimeSessionId: 'nova',
|
runtimeSessionId: 'nova',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('TessDurableSessionRepository', () => {
|
describe('DurableSessionRepository', () => {
|
||||||
let dataDir: string | undefined;
|
let dataDir: string | undefined;
|
||||||
let handle: DbHandle;
|
let handle: DbHandle;
|
||||||
let previousAuthSecret: string | undefined;
|
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 () => {
|
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
||||||
const beforeRestart = new DurableSessionCoordinator(
|
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
new TessDurableSessionRepository(handle.db),
|
|
||||||
);
|
|
||||||
await beforeRestart.create(IDENTITY);
|
await beforeRestart.create(IDENTITY);
|
||||||
await beforeRestart.receive({
|
await beforeRestart.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -92,7 +90,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
await handle.close();
|
await handle.close();
|
||||||
handle = createPgliteDb(dataDir!);
|
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 recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||||
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
||||||
const handled: string[] = [];
|
const handled: string[] = [];
|
||||||
@@ -120,7 +118,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('redacts sensitive durable payloads before persistence', async () => {
|
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.create(IDENTITY);
|
||||||
await coordinator.receive({
|
await coordinator.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -157,7 +155,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
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);
|
await coordinator.create(IDENTITY);
|
||||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
delete process.env['BETTER_AUTH_SECRET'];
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
@@ -177,7 +175,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
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);
|
await coordinator.create(IDENTITY);
|
||||||
const input = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -227,7 +225,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
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);
|
await coordinator.create(IDENTITY);
|
||||||
const inbox = {
|
const inbox = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -255,7 +253,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
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.create(IDENTITY);
|
||||||
await coordinator.receive({
|
await coordinator.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -293,10 +291,10 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
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 coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
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 = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'live-effect',
|
idempotencyKey: 'live-effect',
|
||||||
@@ -333,10 +331,10 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
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 coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
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 = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'mismatch-effect',
|
idempotencyKey: 'mismatch-effect',
|
||||||
@@ -366,10 +364,10 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
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 coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
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 = {
|
const first = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'scoped-effect-one',
|
idempotencyKey: 'scoped-effect-one',
|
||||||
@@ -33,7 +33,7 @@ import type {
|
|||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TessDurableSessionRepository implements DurableSessionStore {
|
export class DurableSessionRepository implements DurableSessionStore {
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||||
@@ -51,7 +51,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
|
|
||||||
const existing = await this.session(identity.sessionId);
|
const existing = await this.session(identity.sessionId);
|
||||||
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
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;
|
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
||||||
// the conversation handle and owner scope remain immutable.
|
// the conversation handle and owner scope remain immutable.
|
||||||
@@ -135,13 +135,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.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]);
|
const entry = toInbox(existing[0]);
|
||||||
if (
|
if (
|
||||||
!sameInbox(entry, record) ||
|
!sameInbox(entry, record) ||
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
!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 };
|
return { accepted: false, status: entry.status };
|
||||||
}
|
}
|
||||||
@@ -224,14 +224,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing[0])
|
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`);
|
|
||||||
const entry = toOutbox(existing[0]);
|
const entry = toOutbox(existing[0]);
|
||||||
if (
|
if (
|
||||||
!sameOutbox(entry, record) ||
|
!sameOutbox(entry, record) ||
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
!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 };
|
return { accepted: false, status: entry.status };
|
||||||
}
|
}
|
||||||
@@ -338,7 +337,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
||||||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
!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> {
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||||
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
||||||
if (!checkpoint) {
|
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
|
const inserted = await this.db
|
||||||
.insert(interactionHandoffs)
|
.insert(interactionHandoffs)
|
||||||
@@ -371,7 +370,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
|
|
||||||
const existing = await this.findHandoff(input.handoffId);
|
const existing = await this.findHandoff(input.handoffId);
|
||||||
if (!existing || !sameHandoff(existing, input)) {
|
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 { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
import type { ProviderOutboxDto } from './durable-session.dto.js';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import {
|
import {
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
type RuntimeProviderRequestContext,
|
type RuntimeProviderRequestContext,
|
||||||
} from './runtime-provider-registry.service.js';
|
} 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
|
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
||||||
* MCP adapters without a server-derived actor/tenant/correlation context.
|
* MCP adapters without a server-derived actor/tenant/correlation context.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TessDurableSessionService {
|
export class DurableSessionService {
|
||||||
private readonly coordinator: DurableSessionCoordinator;
|
private readonly coordinator: DurableSessionCoordinator;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
|
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
|
||||||
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
||||||
) {
|
) {
|
||||||
this.coordinator = new DurableSessionCoordinator(repository);
|
this.coordinator = new DurableSessionCoordinator(repository);
|
||||||
@@ -32,12 +32,12 @@ export class TessDurableSessionService {
|
|||||||
identity.ownerId !== context.actorScope.userId ||
|
identity.ownerId !== context.actorScope.userId ||
|
||||||
identity.tenantId !== context.actorScope.tenantId
|
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);
|
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);
|
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
await this.coordinator.enqueueOutbox({
|
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) {
|
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);
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
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. */
|
/** 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);
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
await this.coordinator.recover(sessionId);
|
await this.coordinator.recover(sessionId);
|
||||||
@@ -100,24 +100,24 @@ export class TessDurableSessionService {
|
|||||||
|
|
||||||
private assertOutboxScope(
|
private assertOutboxScope(
|
||||||
entry: { kind: string; correlationId: string; channelId: string },
|
entry: { kind: string; correlationId: string; channelId: string },
|
||||||
input: TessProviderOutboxDto,
|
input: ProviderOutboxDto,
|
||||||
): void {
|
): void {
|
||||||
if (
|
if (
|
||||||
entry.kind !== 'provider.send' ||
|
entry.kind !== 'provider.send' ||
|
||||||
entry.correlationId !== input.correlationId ||
|
entry.correlationId !== input.correlationId ||
|
||||||
entry.channelId !== input.context.channelId
|
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 (
|
if (
|
||||||
input.context.actorScope.userId !== ownerId ||
|
input.context.actorScope.userId !== ownerId ||
|
||||||
input.context.actorScope.tenantId !== tenantId ||
|
input.context.actorScope.tenantId !== tenantId ||
|
||||||
input.context.correlationId !== input.correlationId
|
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,
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
RuntimeProviderAuditService,
|
RuntimeProviderAuditService,
|
||||||
} from './runtime-provider-registry.service.js';
|
} from './runtime-provider-registry.service.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||||
@@ -88,9 +88,9 @@ describe('Hermes runtime provider reachability', (): void => {
|
|||||||
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
||||||
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
||||||
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
||||||
.overrideProvider(TessDurableSessionService)
|
.overrideProvider(DurableSessionService)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(TessDurableSessionRepository)
|
.overrideProvider(DurableSessionRepository)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(AgentService)
|
.overrideProvider(AgentService)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
@@ -115,6 +115,38 @@ describe('Hermes runtime provider reachability', (): void => {
|
|||||||
await app?.close();
|
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> => {
|
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
|
||||||
if (!app) throw new Error('Nest application did not initialize');
|
if (!app) throw new Error('Nest application did not initialize');
|
||||||
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Observable } from 'rxjs';
|
|||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
import { 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 { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||||
import {
|
import {
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
export class InteractionController {
|
export class InteractionController {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
||||||
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
|
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('sessions')
|
@Get('sessions')
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
type RuntimeAuditSink,
|
type RuntimeAuditSink,
|
||||||
} from '../agent/runtime-provider-registry.service.js';
|
} 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 { AUTH } from '../auth/auth.tokens.js';
|
||||||
import {
|
import {
|
||||||
scopeFromUser,
|
scopeFromUser,
|
||||||
@@ -140,8 +140,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@Inject(RuntimeProviderService)
|
@Inject(RuntimeProviderService)
|
||||||
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(TessDurableSessionService)
|
@Inject(DurableSessionService)
|
||||||
private readonly durableSessions: TessDurableSessionService | null = null,
|
private readonly durableSessions: DurableSessionService | null = null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||||
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { InMemoryMosCoordinationPort } from '@mosaicstack/coord';
|
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
|
||||||
import { CoordService } from './coord.service.js';
|
import { CoordService } from './coord.service.js';
|
||||||
import { CoordController } from './coord.controller.js';
|
import { CoordController } from './coord.controller.js';
|
||||||
import { MosCoordinationController } from './mos-coordination.controller.js';
|
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||||
import {
|
import {
|
||||||
MOS_COORDINATION_CONFIG,
|
COORDINATION_CONFIG,
|
||||||
MOS_COORDINATION_PORT,
|
COORDINATION_PORT,
|
||||||
MosCoordinationService,
|
InteractionCoordinationService,
|
||||||
} from './mos-coordination.service.js';
|
} from './interaction-coordination.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
CoordService,
|
CoordService,
|
||||||
{
|
{
|
||||||
provide: MOS_COORDINATION_PORT,
|
provide: COORDINATION_PORT,
|
||||||
useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(),
|
useFactory: (): InMemoryInteractionCoordinationPort =>
|
||||||
|
new InMemoryInteractionCoordinationPort(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: MOS_COORDINATION_CONFIG,
|
provide: COORDINATION_CONFIG,
|
||||||
useFactory: () => ({
|
useFactory: () => ({
|
||||||
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
||||||
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
MosCoordinationService,
|
InteractionCoordinationService,
|
||||||
],
|
],
|
||||||
controllers: [CoordController, MosCoordinationController],
|
controllers: [CoordController, InteractionCoordinationController],
|
||||||
exports: [CoordService, MosCoordinationService],
|
exports: [CoordService, InteractionCoordinationService],
|
||||||
})
|
})
|
||||||
export class CoordModule {}
|
export class CoordModule {}
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
|
const PATH_METADATA = 'path';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
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' };
|
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 () => {
|
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
|
||||||
const coordination = {
|
const coordination = {
|
||||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
result: 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');
|
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 () => {
|
it('requires a correlation header before invoking the coordination service', async () => {
|
||||||
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
|
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(
|
await expect(
|
||||||
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
|
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 { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
import type {
|
import type {
|
||||||
MosCoordinationObservationDto,
|
InteractionCoordinationObservationDto,
|
||||||
MosCoordinationResponseDto,
|
InteractionCoordinationResponseDto,
|
||||||
MosCoordinationResultDto,
|
InteractionCoordinationResultDto,
|
||||||
CreateMosHandoffDto,
|
CreateHandoffDto,
|
||||||
} from './mos-coordination.dto.js';
|
} from './interaction-coordination.dto.js';
|
||||||
import { MosCoordinationService } from './mos-coordination.service.js';
|
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||||
|
|
||||||
/** Authenticated interaction-plane boundary for the handoff/observe/result-only Mos contract. */
|
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
|
||||||
@Controller('api/coord/mos')
|
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
|
||||||
|
@Controller(['api/coord/interaction', 'api/coord/mos'])
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class MosCoordinationController {
|
export class InteractionCoordinationController {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MosCoordinationService) private readonly coordination: MosCoordinationService,
|
@Inject(InteractionCoordinationService)
|
||||||
|
private readonly coordination: InteractionCoordinationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('handoff')
|
@Post('handoff')
|
||||||
async handoff(
|
async handoff(
|
||||||
@Body() request: CreateMosHandoffDto,
|
@Body() request: CreateHandoffDto,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
): Promise<MosCoordinationResponseDto> {
|
): Promise<InteractionCoordinationResponseDto> {
|
||||||
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ export class MosCoordinationController {
|
|||||||
@Param('handoffId') handoffId: string,
|
@Param('handoffId') handoffId: string,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
): Promise<MosCoordinationObservationDto> {
|
): Promise<InteractionCoordinationObservationDto> {
|
||||||
return {
|
return {
|
||||||
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
||||||
};
|
};
|
||||||
@@ -54,7 +56,7 @@ export class MosCoordinationController {
|
|||||||
@Param('handoffId') handoffId: string,
|
@Param('handoffId') handoffId: string,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
): Promise<MosCoordinationResultDto> {
|
): Promise<InteractionCoordinationResultDto> {
|
||||||
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
import type {
|
import type {
|
||||||
CoordinationObservation,
|
CoordinationObservation,
|
||||||
CoordinationResult,
|
CoordinationResult,
|
||||||
MosHandoffReceipt,
|
HandoffReceipt,
|
||||||
} from '@mosaicstack/coord';
|
} from '@mosaicstack/coord';
|
||||||
|
|
||||||
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
||||||
export interface CreateMosHandoffDto {
|
export interface CreateHandoffDto {
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
missionId?: string;
|
missionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationResponseDto {
|
export interface InteractionCoordinationResponseDto {
|
||||||
receipt: MosHandoffReceipt;
|
receipt: HandoffReceipt;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationObservationDto {
|
export interface InteractionCoordinationObservationDto {
|
||||||
observation: CoordinationObservation;
|
observation: CoordinationObservation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationResultDto {
|
export interface InteractionCoordinationResultDto {
|
||||||
result: CoordinationResult;
|
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 { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
InMemoryMosCoordinationPort,
|
InMemoryInteractionCoordinationPort,
|
||||||
type MosCoordinationPort,
|
type InteractionCoordinationPort,
|
||||||
type MosHandoff,
|
type Handoff,
|
||||||
} from '@mosaicstack/coord';
|
} from '@mosaicstack/coord';
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
import {
|
import {
|
||||||
MosCoordinationService,
|
InteractionCoordinationService,
|
||||||
type MosCoordinationConfig,
|
type InteractionCoordinationConfig,
|
||||||
type MosCoordinationGatewayError,
|
type InteractionCoordinationGatewayError,
|
||||||
} from './mos-coordination.service.js';
|
} from './interaction-coordination.service.js';
|
||||||
|
|
||||||
const context: RuntimeProviderRequestContext = {
|
const context: RuntimeProviderRequestContext = {
|
||||||
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||||
@@ -17,28 +17,28 @@ const context: RuntimeProviderRequestContext = {
|
|||||||
correlationId: 'corr-1',
|
correlationId: 'corr-1',
|
||||||
};
|
};
|
||||||
|
|
||||||
const config: MosCoordinationConfig = {
|
const config: InteractionCoordinationConfig = {
|
||||||
interactionAgentId: 'Nova',
|
interactionAgentId: 'Nova',
|
||||||
orchestrationAgentId: 'Conductor',
|
orchestrationAgentId: 'Conductor',
|
||||||
};
|
};
|
||||||
|
|
||||||
function service(
|
function service(
|
||||||
port: MosCoordinationPort = new InMemoryMosCoordinationPort(),
|
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
|
||||||
options: {
|
options: {
|
||||||
config?: MosCoordinationConfig;
|
config?: InteractionCoordinationConfig;
|
||||||
handoffIdFactory?: () => string;
|
handoffIdFactory?: () => string;
|
||||||
} = {},
|
} = {},
|
||||||
): MosCoordinationService {
|
): InteractionCoordinationService {
|
||||||
return new MosCoordinationService(
|
return new InteractionCoordinationService(
|
||||||
port,
|
port,
|
||||||
options.config ?? config,
|
options.config ?? config,
|
||||||
options.handoffIdFactory ?? (() => 'handoff-1'),
|
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> => {
|
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);
|
const coordination = service(adapter);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -53,8 +53,8 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
correlationId: 'corr-1',
|
correlationId: 'corr-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
|
||||||
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
||||||
|
|
||||||
const followUpContext = { ...context, correlationId: 'corr-2' };
|
const followUpContext = { ...context, correlationId: 'corr-2' };
|
||||||
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
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({
|
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
targetAgentId: 'Conductor',
|
targetAgentId: 'Conductor',
|
||||||
status: 'completed',
|
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> => {
|
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 handoff = vi.spyOn(adapter, 'handoff');
|
||||||
const coordination = service(adapter, {
|
const coordination = service(adapter, {
|
||||||
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
||||||
@@ -82,12 +82,12 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
),
|
),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'unconfigured_requester',
|
code: 'unconfigured_requester',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
expect(handoff).not.toHaveBeenCalled();
|
expect(handoff).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
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 handoff = vi.spyOn(adapter, 'handoff');
|
||||||
const coordination = service(adapter, {
|
const coordination = service(adapter, {
|
||||||
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
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> => {
|
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 observe = vi.spyOn(adapter, 'observe');
|
||||||
const result = vi.spyOn(adapter, 'result');
|
const result = vi.spyOn(adapter, 'result');
|
||||||
const coordination = service(adapter);
|
const coordination = service(adapter);
|
||||||
@@ -118,10 +118,10 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
};
|
};
|
||||||
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
code: 'cross_tenant_forbidden',
|
code: 'cross_tenant_forbidden',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
code: 'cross_tenant_forbidden',
|
code: 'cross_tenant_forbidden',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
expect(observe).not.toHaveBeenCalled();
|
expect(observe).not.toHaveBeenCalled();
|
||||||
expect(result).not.toHaveBeenCalled();
|
expect(result).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -132,8 +132,8 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
const delivered = new Promise<void>((resolve: () => void): void => {
|
const delivered = new Promise<void>((resolve: () => void): void => {
|
||||||
release = resolve;
|
release = resolve;
|
||||||
});
|
});
|
||||||
const adapter: MosCoordinationPort = {
|
const adapter: InteractionCoordinationPort = {
|
||||||
handoff: vi.fn(async (handoff: MosHandoff) => {
|
handoff: vi.fn(async (handoff: Handoff) => {
|
||||||
await delivered;
|
await delivered;
|
||||||
return {
|
return {
|
||||||
handoffId: handoff.handoffId,
|
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> => {
|
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 handoff = vi.spyOn(adapter, 'handoff');
|
||||||
const coordination = service(adapter);
|
const coordination = service(adapter);
|
||||||
await coordination.handoff(
|
await coordination.handoff(
|
||||||
@@ -181,23 +181,23 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'handoff_conflict',
|
code: 'handoff_conflict',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
await expect(
|
await expect(
|
||||||
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'invalid_request',
|
code: 'invalid_request',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
await expect(
|
await expect(
|
||||||
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'invalid_request',
|
code: 'invalid_request',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
expect(handoff).toHaveBeenCalledTimes(1);
|
expect(handoff).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
||||||
const adapter: MosCoordinationPort = {
|
const adapter: InteractionCoordinationPort = {
|
||||||
handoff: vi.fn(async (handoff: MosHandoff) => ({
|
handoff: vi.fn(async (handoff: Handoff) => ({
|
||||||
handoffId: handoff.handoffId,
|
handoffId: handoff.handoffId,
|
||||||
targetAgentId: 'Unexpected',
|
targetAgentId: 'Unexpected',
|
||||||
status: 'accepted' as const,
|
status: 'accepted' as const,
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
MosCoordinationClient,
|
InteractionCoordinationClient,
|
||||||
type CoordinationObservation,
|
type CoordinationObservation,
|
||||||
type CoordinationResult,
|
type CoordinationResult,
|
||||||
type CoordinationScope,
|
type CoordinationScope,
|
||||||
type MosCoordinationIdentity,
|
type InteractionCoordinationIdentity,
|
||||||
type MosCoordinationPort,
|
type InteractionCoordinationPort,
|
||||||
type MosHandoffReceipt,
|
type HandoffReceipt,
|
||||||
} from '@mosaicstack/coord';
|
} from '@mosaicstack/coord';
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
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 COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||||
export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG');
|
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||||
|
|
||||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
const MAX_TRACKED_HANDOFFS = 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_CONTEXT_LENGTH = 8_192;
|
||||||
const MAX_MISSION_ID_LENGTH = 128;
|
const MAX_MISSION_ID_LENGTH = 128;
|
||||||
|
|
||||||
export interface MosCoordinationConfig {
|
export interface InteractionCoordinationConfig {
|
||||||
interactionAgentId?: string;
|
interactionAgentId?: string;
|
||||||
orchestrationAgentId?: string;
|
orchestrationAgentId?: string;
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ interface HandoffOwner {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NormalizedMosHandoffRequest {
|
interface NormalizedHandoffRequest {
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
@@ -42,31 +42,31 @@ interface NormalizedMosHandoffRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TrackedHandoff {
|
interface TrackedHandoff {
|
||||||
request: NormalizedMosHandoffRequest;
|
request: NormalizedHandoffRequest;
|
||||||
receipt: Promise<MosHandoffReceipt>;
|
receipt: Promise<HandoffReceipt>;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gateway authority boundary for the interaction agent. It derives requester,
|
* Gateway authority boundary for the interaction agent. It derives requester,
|
||||||
* actor, and tenant from trusted server configuration and authentication; no
|
* 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()
|
@Injectable()
|
||||||
export class MosCoordinationService {
|
export class InteractionCoordinationService {
|
||||||
private readonly owners = new Map<string, HandoffOwner>();
|
private readonly owners = new Map<string, HandoffOwner>();
|
||||||
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort,
|
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||||
@Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig,
|
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handoff(
|
async handoff(
|
||||||
request: CreateMosHandoffDto,
|
request: CreateHandoffDto,
|
||||||
context: RuntimeProviderRequestContext,
|
context: RuntimeProviderRequestContext,
|
||||||
): Promise<MosHandoffReceipt> {
|
): Promise<HandoffReceipt> {
|
||||||
this.pruneExpiredTracking();
|
this.pruneExpiredTracking();
|
||||||
const normalized = this.normalizeRequest(request);
|
const normalized = this.normalizeRequest(request);
|
||||||
const scope = this.scope(context);
|
const scope = this.scope(context);
|
||||||
@@ -74,9 +74,9 @@ export class MosCoordinationService {
|
|||||||
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
if (!sameRequest(existing.request, normalized)) {
|
if (!sameRequest(existing.request, normalized)) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'handoff_conflict',
|
'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;
|
return existing.receipt;
|
||||||
@@ -122,9 +122,9 @@ export class MosCoordinationService {
|
|||||||
|
|
||||||
private async deliverHandoff(
|
private async deliverHandoff(
|
||||||
handoffId: string,
|
handoffId: string,
|
||||||
request: NormalizedMosHandoffRequest,
|
request: NormalizedHandoffRequest,
|
||||||
scope: CoordinationScope,
|
scope: CoordinationScope,
|
||||||
): Promise<MosHandoffReceipt> {
|
): Promise<HandoffReceipt> {
|
||||||
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
||||||
const owner: HandoffOwner = {
|
const owner: HandoffOwner = {
|
||||||
actorId: scope.actorId,
|
actorId: scope.actorId,
|
||||||
@@ -135,9 +135,9 @@ export class MosCoordinationService {
|
|||||||
};
|
};
|
||||||
const existing = this.owners.get(receipt.handoffId);
|
const existing = this.owners.get(receipt.handoffId);
|
||||||
if (existing !== undefined && !sameOwner(existing, owner)) {
|
if (existing !== undefined && !sameOwner(existing, owner)) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'handoff_conflict',
|
'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);
|
this.owners.set(receipt.handoffId, owner);
|
||||||
@@ -145,21 +145,21 @@ export class MosCoordinationService {
|
|||||||
return receipt;
|
return receipt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private client(handoffIdFactory?: () => string): MosCoordinationClient {
|
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
|
||||||
return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private identity(): MosCoordinationIdentity {
|
private identity(): InteractionCoordinationIdentity {
|
||||||
const interactionAgentId = this.config.interactionAgentId?.trim();
|
const interactionAgentId = this.config.interactionAgentId?.trim();
|
||||||
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
||||||
if (!interactionAgentId) {
|
if (!interactionAgentId) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'unconfigured_requester',
|
'unconfigured_requester',
|
||||||
'Interaction agent identity is not configured',
|
'Interaction agent identity is not configured',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!orchestrationAgentId) {
|
if (!orchestrationAgentId) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'unconfigured_target',
|
'unconfigured_target',
|
||||||
'Orchestration agent identity is not configured',
|
'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) {
|
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(
|
const idempotencyKey = this.requiredString(
|
||||||
request.idempotencyKey,
|
request.idempotencyKey,
|
||||||
@@ -203,14 +206,17 @@ export class MosCoordinationService {
|
|||||||
|
|
||||||
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'invalid_request',
|
'invalid_request',
|
||||||
`Mos handoff ${field} must be a string`,
|
`Handoff ${field} must be a string`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const normalized = value.trim();
|
const normalized = value.trim();
|
||||||
if (normalized.length === 0 || normalized.length > maximumLength) {
|
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;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -245,23 +251,23 @@ export class MosCoordinationService {
|
|||||||
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
||||||
const owner = this.owners.get(handoffId);
|
const owner = this.owners.get(handoffId);
|
||||||
if (owner === undefined) {
|
if (owner === undefined) {
|
||||||
throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found');
|
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
owner.tenantId !== scope.tenantId ||
|
owner.tenantId !== scope.tenantId ||
|
||||||
owner.actorId !== scope.actorId ||
|
owner.actorId !== scope.actorId ||
|
||||||
owner.requesterAgentId !== scope.requesterAgentId
|
owner.requesterAgentId !== scope.requesterAgentId
|
||||||
) {
|
) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'cross_tenant_forbidden',
|
'cross_tenant_forbidden',
|
||||||
'Mos handoff is outside the authenticated scope',
|
'Handoff is outside the authenticated scope',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return owner;
|
return owner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MosCoordinationGatewayErrorCode =
|
export type InteractionCoordinationGatewayErrorCode =
|
||||||
| 'cross_tenant_forbidden'
|
| 'cross_tenant_forbidden'
|
||||||
| 'handoff_conflict'
|
| 'handoff_conflict'
|
||||||
| 'invalid_request'
|
| 'invalid_request'
|
||||||
@@ -277,10 +283,7 @@ function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameRequest(
|
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
|
||||||
left: NormalizedMosHandoffRequest,
|
|
||||||
right: NormalizedMosHandoffRequest,
|
|
||||||
): boolean {
|
|
||||||
return (
|
return (
|
||||||
left.idempotencyKey === right.idempotencyKey &&
|
left.idempotencyKey === right.idempotencyKey &&
|
||||||
left.summary === right.summary &&
|
left.summary === right.summary &&
|
||||||
@@ -289,12 +292,12 @@ function sameRequest(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MosCoordinationGatewayError extends Error {
|
export class InteractionCoordinationGatewayError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code: MosCoordinationGatewayErrorCode,
|
readonly code: InteractionCoordinationGatewayErrorCode,
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
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.
|
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
||||||
**Phase:** Execution (workstream W1 in planning-complete state)
|
**Phase:** Execution (workstream W1 in planning-complete state)
|
||||||
**Current Workstream:** W1 — Federation v1
|
**Current Workstream:** W1 — Federation v1
|
||||||
**Progress:** 0 / 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)
|
**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
|
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
||||||
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
||||||
|
|
||||||
@@ -67,11 +67,12 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
|||||||
|
|
||||||
## Workstreams
|
## Workstreams
|
||||||
|
|
||||||
| # | ID | Name | Status | Manifest | Notes |
|
| # | ID | Name | Status | Manifest | Notes |
|
||||||
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
| --- | ---- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
| W2 | 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 |
|
| W3 | KBN | Native Kanban and canonical task SOT | planning-complete | [docs/native-kanban-sot/MISSION-MANIFEST.md](./native-kanban-sot/MISSION-MANIFEST.md) | P0–P3; issue #751; implementation held until canon merge |
|
||||||
|
| W4+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||||
|
|
||||||
### Likely Additional Workstreams (Not Yet Declared)
|
### Likely Additional Workstreams (Not Yet Declared)
|
||||||
|
|
||||||
|
|||||||
136
docs/PRD.md
136
docs/PRD.md
@@ -1100,3 +1100,139 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
|||||||
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
||||||
|
|
||||||
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fleet Declarative Configuration Management (#758)
|
||||||
|
|
||||||
|
### Status and objective
|
||||||
|
|
||||||
|
- **Requirement ID:** `FCM-PRD-001`
|
||||||
|
- **Status:** approved architecture; M0 documentation gate in progress
|
||||||
|
- **Authority:** issue #758 and the independently approved baseline plan
|
||||||
|
|
||||||
|
Provide one understandable, schema-validated lifecycle for the local Mosaic fleet. The operator-owned YAML/JSON roster is the canonical desired-state input. Generated agent environment files, systemd enablement, tmux sessions, heartbeat state, and installed framework assets are derived or observed state. Mutations must pass through one shared compiler/reconciler and must be previewable, atomic where possible, recoverable, automation-safe, and non-destructive toward unmanaged resources.
|
||||||
|
|
||||||
|
### Normative scope
|
||||||
|
|
||||||
|
#### In scope for M0–M5
|
||||||
|
|
||||||
|
1. A narrow v2 YAML/JSON roster for local tmux/systemd fleets.
|
||||||
|
2. One executable structural contract with schema/parser parity and canonical snake_case output.
|
||||||
|
3. Semantic validation through the existing baseline plus `roles.local` profile/persona/provision resolver; a parallel role resolver is forbidden.
|
||||||
|
4. Canonical classes `code`, `review`, `security-review`, `validator`, `merge-gate`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`, including documented legacy aliases.
|
||||||
|
5. Read/validate/plan/apply/migrate, full local fleet-agent CRUD, lifecycle, status, verify, stable JSON output, and documented exit codes.
|
||||||
|
6. Deterministic `.env.generated` projections, a strict non-shell `.env.local` allowlist, generation/digest stamping, and fail-closed quarantine of forbidden legacy keys.
|
||||||
|
7. v1 inventory, preview, field-complete migration, canary cutover, rollback, compatibility aliases, and explicit disposition of every shipped example/profile.
|
||||||
|
8. Documentation, packaging/update checks, clean-install/cold-start dogfood, and independent correctness, security, validator, and merge gates.
|
||||||
|
|
||||||
|
#### Out of scope for M0–M5
|
||||||
|
|
||||||
|
- Kubernetes-style resource envelopes.
|
||||||
|
- Remote/SSH reconciliation or distributed placement mutation.
|
||||||
|
- Connector/Matrix/Discord lifecycle mutation.
|
||||||
|
- Secret-reference or credential-provider schema.
|
||||||
|
- Arbitrary command or channel overrides.
|
||||||
|
- Gateway `/api/agents` mapping, control-plane convergence, UI configuration storage, or rename of that separate DB-backed catalog.
|
||||||
|
- Live-fleet mutation during M0.
|
||||||
|
|
||||||
|
Each excluded capability requires a separate post-M5 PRD and threat model. Existing v1 remote/connector fields are inventory-only: local apply must reject them without invoking systemd or tmux.
|
||||||
|
|
||||||
|
### Authority and identity decisions
|
||||||
|
|
||||||
|
- The roster owns fleet membership, launch policy, and persisted lifecycle intent.
|
||||||
|
- Role/persona contracts are product reference data; `roles.local` is the update-surviving local extension layer.
|
||||||
|
- Tess and Ultron are configurable instance/display names, not schema identities.
|
||||||
|
- `validator` issues the independent final validation certificate but cannot approve-to-land or merge.
|
||||||
|
- `merge-gate` remains the sole approve-to-land and merge authority after required review, security, validation, CI, and queue gates.
|
||||||
|
- `orchestrator` may apply validated owner-policy-compliant topology changes and grant/revoke bounded capacity leases.
|
||||||
|
- `team-leader` may accept/release and use a named lease but cannot change global topology, re-lease capacity, or gain merge authority.
|
||||||
|
- `review` and `security-review` provide independent correctness and security records respectively; neither authors the reviewed change.
|
||||||
|
- `interaction` is request/status only. `enhancer` proposes fleet improvements. `code` authors implementation but cannot self-review.
|
||||||
|
- Operator policy remains the exception, pause, and lease-revocation boundary.
|
||||||
|
|
||||||
|
A capacity lease names existing agents, purpose, and expiry. It never changes class, runtime, tools, credentials, roster ownership, or merge authority.
|
||||||
|
|
||||||
|
### Lifecycle and generated-state decisions
|
||||||
|
|
||||||
|
The normative dimensions are `enabled`, persisted `desired_state: running|stopped`, and observed `running|stopped|error|unknown|unmanaged`.
|
||||||
|
|
||||||
|
1. Fresh create defaults to enabled and stopped; `create --start` persists running.
|
||||||
|
2. v1 migration preserves known observed running/stopped state. Unknown state blocks apply for that entry.
|
||||||
|
3. Start/stop without `--persist` is transient and reports drift; the next apply/reboot restores persisted intent.
|
||||||
|
4. Start/stop with `--persist` atomically updates generation and converges the local unit/session.
|
||||||
|
5. Restart does not change desired state and rejects stopped entries unless explicitly started.
|
||||||
|
6. Apply acts only on local, enabled, roster-owned entries. Ownership must be proven before stale projections are quarantined or removed; fuzzy names never authorize stopping an unmanaged session.
|
||||||
|
7. Rollback restores roster/projection generation and captured unit enablement, stops processes introduced by the failed generation, and never starts an agent that was stopped before cutover.
|
||||||
|
8. The systemd unit reads only `%i.env.generated` after migration. `.env.local` is parsed as data, cannot shadow authoritative keys, and never uses shell `source`, `eval`, expansion, or command substitution.
|
||||||
|
9. `MOSAIC_AGENT_COMMAND`, channel flags, credential variables, and unknown agent keys are forbidden. Migration reports key names and content hashes only—never values—and blocks launch/apply until disposition.
|
||||||
|
|
||||||
|
### Functional requirements
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| FCM-FR-01 | Show and validate YAML/JSON using one structural contract and shared semantic role/topology validation. |
|
||||||
|
| FCM-FR-02 | Produce a deterministic, non-mutating desired-versus-observed plan covering roster, projections, units, sessions, installed assets, and orphans. |
|
||||||
|
| FCM-FR-03 | Apply under a lock with expected-generation checks, atomic writes/backups, ordered convergence, post-verification, and machine-readable recovery data. |
|
||||||
|
| FCM-FR-04 | Provide create, inspect, update, remove, list, start, stop, restart, status, validate, reconcile, doctor, dry-run, and automation-safe operations. |
|
||||||
|
| FCM-FR-05 | Validate duplicate names, unsupported classes/runtimes/models/options, topology cycles, missing role contracts, stale generated state, unmanaged sessions, unit drift, and socket ambiguity. |
|
||||||
|
| FCM-FR-06 | Generate deterministic, mode-0600, digest-stamped launch projections; safely parse only allowlisted local operational overrides. |
|
||||||
|
| FCM-FR-07 | Read v1 for one deprecation window, write v2 after migration, preserve known lifecycle intent, and provide preview/canary/rollback. |
|
||||||
|
| FCM-FR-08 | Classify every shipped example/profile as migrated, versioned compatibility fixture, or retired with replacement. |
|
||||||
|
| FCM-FR-09 | Report desired, observed, generation, drift, readiness, ownership, and failing plane without exposing privileged values. |
|
||||||
|
| FCM-FR-10 | Keep gateway-backed `mosaic agent` records explicitly separate from local `mosaic fleet` desired state. |
|
||||||
|
|
||||||
|
### Non-functional requirements
|
||||||
|
|
||||||
|
- **Security:** fail closed on command/credential/unknown overrides; reject traversal, injection, shadowing, and unauthorized topology/lifecycle actions; never expose secret or command values.
|
||||||
|
- **Reliability:** lock plus expected generation; temporary write, fsync, atomic rename, recoverable backup; deterministic idempotent replay; ordered rollback on partial failure.
|
||||||
|
- **Safety:** no destructive inference from stale names; no local actions for remote/schema-only entries; stopped agents remain stopped through migration and reboot.
|
||||||
|
- **Compatibility:** canonical snake_case serialization with bounded v1 camelCase/alias input support and explicit warnings.
|
||||||
|
- **Observability:** stable text/JSON status, drift, plan, migration, and recovery output with documented exit codes `0` success, `2` invalid, `3` drift, `4` conflict, `5` partial failure, and `6` policy denial.
|
||||||
|
- **Maintainability:** schema, roster load, profiles, provision, migration, and apply share the existing role-resolution implementation.
|
||||||
|
- **Documentation:** every field, command, transition, migration rule, recovery workflow, class power, and example is linked from the fleet docs IA and validated in CI.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
| ID | Acceptance criterion |
|
||||||
|
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| FCM-AC-01 | `docs/PRD.md`, `docs/TASKS.md`, docs-IA checklist, and example/profile inventory are approved before implementation. |
|
||||||
|
| FCM-AC-02 | YAML and JSON positive/negative, round-trip, unknown-field, enum, duplicate, topology, and property/fuzz tests prove schema/parser parity and canonical serialization. |
|
||||||
|
| FCM-AC-03 | Every class resolves through the existing profile/persona resolver; authority and lease tests enforce the normative matrix. |
|
||||||
|
| FCM-AC-04 | Every shipped example/profile has a CI-valid migrate/compatibility/retire disposition with no unresolved class at M1 exit. |
|
||||||
|
| FCM-AC-05 | Validate/show/plan are deterministic and non-mutating; JSON shapes and exit codes are contract-tested. |
|
||||||
|
| FCM-AC-06 | Generated/local env precedence, mode, digest, forbidden shadowing, command injection, quarantine, and no-value diagnostics pass independent security tests. |
|
||||||
|
| FCM-AC-07 | CRUD is generation-guarded, atomic/recoverable, idempotent, concurrency-tested, and creates stopped agents unless start is explicitly persisted. |
|
||||||
|
| FCM-AC-08 | Apply/lifecycle exactly implements the transition contract, including transient/persisted operations, reboot, partial failure, and rollback. |
|
||||||
|
| FCM-AC-09 | Remote/schema-only and unmanaged entries receive zero local lifecycle calls; local targeting covers named and default tmux sockets exactly. |
|
||||||
|
| FCM-AC-10 | Status/verify/doctor expose all state planes and actionable drift without secret, credential, or privileged command values. |
|
||||||
|
| FCM-AC-11 | v1 migration handles every mapped field, known/unknown observed state, aliases, env quarantine, current 9-managed/3-unmanaged synthetic fixture, canary, and rollback. |
|
||||||
|
| FCM-AC-12 | `fleet add/remove` compatibility aliases and v1 reads remain for the approved deprecation window while v2 writers emit only v2. |
|
||||||
|
| FCM-AC-13 | Package/install/update tests prove schema, tools, units, roles, docs, and examples ship together while site-owned state survives. |
|
||||||
|
| FCM-AC-14 | Fleet documentation checklist is complete, links/format/examples validate, and operator recovery procedures match tested behavior. |
|
||||||
|
| FCM-AC-15 | Independent correctness review, security review, validator certificate, terminal-green CI, and merge-gate approval complete before issue closure. |
|
||||||
|
|
||||||
|
### Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation / verification |
|
||||||
|
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Schema and parser drift create false validation | One executable contract or bidirectional parity tests; shared semantic resolver. |
|
||||||
|
| Apply starts intentionally stopped agents | Persist separate desired state; migration preserves known observed state; reboot/rollback tests. |
|
||||||
|
| Preserved env files become a hidden control plane | Generated-only unit input; strict local allowlist; shadow rejection and quarantine before start. |
|
||||||
|
| Command, secret, or credential values leak | Values never enter v2 output; key-name/hash-only diagnostics; adversarial security tests and review. |
|
||||||
|
| Stale artifacts cause destructive cleanup | Proof-of-ownership requirement; unmanaged/remote zero-call tests; deterministic plan before apply. |
|
||||||
|
| Concurrent writers or crashes corrupt roster | Lock, expected generation, fsync/rename, backup, failure injection, and recovery plan. |
|
||||||
|
| New compiler duplicates role logic | Hard prohibition on parallel resolver; parity tests across profile, provision, roster, migration, apply. |
|
||||||
|
| Control-plane naming confuses automation | Explicit local `mosaic fleet` versus gateway DB catalog documentation; no implicit mapping in M1–M5. |
|
||||||
|
| Legacy examples silently teach invalid classes | Complete disposition inventory and M1 CI exit gate. |
|
||||||
|
|
||||||
|
### Verification and milestone intent
|
||||||
|
|
||||||
|
- **M0:** requirements, authority, lifecycle, migration mapping, TASKS DAG, docs IA, and legacy inventory approved; no implementation or live mutation.
|
||||||
|
- **M1:** narrow v2 compiler, shared resolver, roles/aliases, validate/show/plan, and all shipped example/profile dispositions.
|
||||||
|
- **M2:** safe launch projection and generation-guarded atomic CRUD; command/credential quarantine proven before lifecycle.
|
||||||
|
- **M3:** local-only apply/lifecycle/status/verify/doctor implementing the full transition table.
|
||||||
|
- **M4:** field-complete v1 migration, compatibility window, orphan inventory, canary, and rollback.
|
||||||
|
- **M5:** accepted documentation IA, package/update checks, clean-install dogfood, independent correctness/security/validator evidence, and merge-gate release approval.
|
||||||
|
|
||||||
|
Detailed delivery dependencies and acceptance mappings are canonical in `docs/TASKS.md`. Documentation acceptance is tracked in [`docs/scratchpads/758-fleet-config-docs-ia-checklist.md`](scratchpads/758-fleet-config-docs-ia-checklist.md), and shipped artifact disposition is inventoried in [`docs/tasks/758-legacy-example-profile-disposition.md`](tasks/758-legacy-example-profile-disposition.md).
|
||||||
|
|||||||
50
docs/SITEMAP.md
Normal file
50
docs/SITEMAP.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Documentation Sitemap
|
||||||
|
|
||||||
|
## Fleet declarative configuration management
|
||||||
|
|
||||||
|
- [Normative requirements](PRD.md#fleet-declarative-configuration-management-758) — issue #758 scope, authority, lifecycle, migration, acceptance, risks, and milestones.
|
||||||
|
- [M0–M5 delivery DAG](TASKS.md#w4--fleet-declarative-configuration-management-758) — one-card/one-PR implementation order and independent gates.
|
||||||
|
- [Documentation IA acceptance checklist](scratchpads/758-fleet-config-docs-ia-checklist.md) — required paths, owners, evidence, and exit checks.
|
||||||
|
- [Legacy example/profile disposition inventory](tasks/758-legacy-example-profile-disposition.md) — shipped artifacts and M1 migration decisions.
|
||||||
|
|
||||||
|
## 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,12 @@
|
|||||||
|
|
||||||
## Workstream Rollup
|
## Workstream Rollup
|
||||||
|
|
||||||
| id | status | workstream | progress | tasks file | notes |
|
| id | status | workstream | progress | tasks file | notes |
|
||||||
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
| --- | ----------------- | ------------------------ | ----------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
||||||
|
| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges |
|
||||||
|
| W4 | in-progress | Fleet declarative config | M0 / 6 milestones | [W4 DAG below](#w4--fleet-declarative-configuration-management-758) | Issue #758; M0 requirements/docs only; implementation blocked on M0 gates |
|
||||||
|
|
||||||
## Cross-Cutting Tracking
|
## Cross-Cutting Tracking
|
||||||
|
|
||||||
@@ -91,3 +93,64 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
## #633 — comms-block emitter + FLEET-LAUNCH runbook — feat/633-comms-block-runbook
|
## #633 — comms-block emitter + FLEET-LAUNCH runbook — feat/633-comms-block-runbook
|
||||||
|
|
||||||
- Status: implemented + tested (TDD). `mosaic fleet comms-block <role> [--host]` wraps resolveCommsBlock → readFleetCommsBlock; fails loud (stderr + exit 1) on unknown role / missing roster instead of silent empty. docs/fleet/FLEET-LAUNCH.md runbook: worker path + orchestrator .env fold (MOSAIC_AGENT_COMMAND; line-41 [-z] short-circuits line-44 yolo hardcode) + 3 launch gotchas + #632 preserve note + North-Star 4-field arc (harness ✅/model ✅ roster-native today; yolo + command/channels = PATH B #636). 177 fleet+comms tests green (6 new resolveCommsBlock cases). PATH A of the A→B→webUI arc. Detail: scratchpads/633-comms-block-runbook.md.
|
- Status: implemented + tested (TDD). `mosaic fleet comms-block <role> [--host]` wraps resolveCommsBlock → readFleetCommsBlock; fails loud (stderr + exit 1) on unknown role / missing roster instead of silent empty. docs/fleet/FLEET-LAUNCH.md runbook: worker path + orchestrator .env fold (MOSAIC_AGENT_COMMAND; line-41 [-z] short-circuits line-44 yolo hardcode) + 3 launch gotchas + #632 preserve note + North-Star 4-field arc (harness ✅/model ✅ roster-native today; yolo + command/channels = PATH B #636). 177 fleet+comms tests green (6 new resolveCommsBlock cases). PATH A of the A→B→webUI arc. Detail: scratchpads/633-comms-block-runbook.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## W4 — Fleet declarative configuration management (#758)
|
||||||
|
|
||||||
|
**Rules:** The table below is the canonical M0–M5 dependency DAG. Each delivery card owns one short-lived branch and one PR. Gate cards (`*-ROR`, `*-SEC`, `*-VAL`, `*-MERGE`) independently attest to the referenced delivery PR and do not author that PR. No implementation starts until `FCM-M0-MERGE` is complete. `done` requires merged PR, terminal-green CI, and linked tracking closure/evidence.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||||
|
| ------------ | ----------- | -------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----- | ---------------------------------- | ----------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| FCM-M0-01 | in-progress | Ratify requirements, authority/lifecycle/migration decisions, DAG, docs IA checklist, and shipped artifact inventory | #758 | haiku | stack | docs/issue-758-m0 | — | 18K | One docs-only PR; maps FCM-AC-01; no source/schema/roles/examples/systemd/live changes |
|
||||||
|
| FCM-M0-ROR | not-started | Independent requirements/content review of M0 PR | #758 | sonnet | stack | — | FCM-M0-01 | 8K | Verify approved plan fidelity, DAG completeness, links, and every card→AC mapping; non-author attestation |
|
||||||
|
| FCM-M0-SEC | not-started | Independent security review of authority, quarantine, lifecycle, and migration requirements | #758 | sonnet | stack | — | FCM-M0-01 | 8K | Threat-model requirements only; verify no secret-value handling and no surprise-start path; non-author attestation |
|
||||||
|
| FCM-M0-VAL | not-started | Validator certificate for M0 acceptance baseline | #758 | sonnet | stack | — | FCM-M0-ROR, FCM-M0-SEC | 6K | Confirm FCM-AC-01 and no unresolved architecture blocker; validator cannot merge |
|
||||||
|
| FCM-M0-MERGE | not-started | Merge-gate approval and squash merge of M0 PR | #758 | haiku | stack | — | FCM-M0-VAL | 3K | Terminal-green CI required; unlocks implementation |
|
||||||
|
| FCM-M1-01 | not-started | Implement narrow v2 executable schema, canonical serialization, and schema/parser parity suite | #758 | codex | stack | feat/fcm-v2-contract | FCM-M0-MERGE | 30K | One PR; FCM-AC-02; structural validation only, no lifecycle mutation |
|
||||||
|
| FCM-M1-02 | not-started | Share existing profile/persona/provision resolver for roster semantic validation and topology policy | #758 | codex | stack | feat/fcm-shared-role-validation | FCM-M1-01 | 28K | One PR; FCM-AC-03; parallel resolver forbidden |
|
||||||
|
| FCM-M1-03 | not-started | Add/ratify validator, team-leader, interaction role contracts, aliases, authority, and lease tests | #758 | codex | stack | feat/fcm-role-authority | FCM-M1-02 | 24K | One PR; FCM-AC-03; merge-gate remains sole merger |
|
||||||
|
| FCM-M1-04 | not-started | Resolve every shipped example/profile disposition and add CI validation through shared contract/resolver | #758 | codex | stack | feat/fcm-example-profile-migration | FCM-M1-03 | 28K | One PR; FCM-AC-04; inventory rows cannot remain decision-required |
|
||||||
|
| FCM-M1-05 | not-started | Implement non-mutating config show, validate, and deterministic plan with stable JSON/exit codes | #758 | codex | stack | feat/fcm-config-read-plan | FCM-M1-02 | 32K | One PR; FCM-AC-05, FCM-AC-09, FCM-AC-10 |
|
||||||
|
| FCM-M1-DOC | not-started | Publish v2 fields, roles/leases, desired-vs-observed, and example/profile disposition docs | #758 | haiku | stack | docs/fcm-m1-contract | FCM-M1-03, FCM-M1-04, FCM-M1-05 | 16K | One PR; FCM-AC-14; update sitemap and docs checklist evidence |
|
||||||
|
| FCM-M1-ROR | not-started | Independent correctness review of all M1 delivery PRs | #758 | sonnet | stack | — | FCM-M1-01, FCM-M1-02, FCM-M1-03, FCM-M1-04, FCM-M1-05, FCM-M1-DOC | 14K | Exact-head reviews; schema/parser/resolver parity and docs checked |
|
||||||
|
| FCM-M1-SEC | not-started | Independent security review of validation, authority, aliases, and input hardening | #758 | sonnet | stack | — | FCM-M1-01, FCM-M1-02, FCM-M1-03, FCM-M1-04, FCM-M1-05 | 12K | Fuzz/injection/topology/policy findings must be resolved |
|
||||||
|
| FCM-M1-VAL | not-started | Validator certificate for M1 contract/compiler exit | #758 | sonnet | stack | — | FCM-M1-ROR, FCM-M1-SEC | 8K | Certify FCM-AC-02–05 and no mutation/lifecycle path |
|
||||||
|
| FCM-M1-MERGE | not-started | Merge-gate approval for M1 completion | #758 | haiku | stack | — | FCM-M1-VAL | 4K | All M1 PRs merged, terminal-green, inventory resolved |
|
||||||
|
| FCM-M2-01 | not-started | Implement deterministic mode-0600 `.env.generated` projection with generation/digest stamps | #758 | codex | stack | feat/fcm-generated-env | FCM-M1-MERGE | 30K | One PR; FCM-AC-06; no unit launch migration yet |
|
||||||
|
| FCM-M2-02 | not-started | Implement strict data-only `.env.local` parser, shadow rejection, and forbidden legacy-key quarantine | #758 | codex | stack | feat/fcm-local-env-quarantine | FCM-M2-01 | 34K | One PR; FCM-AC-06; never output values or privileged commands |
|
||||||
|
| FCM-M2-03 | not-started | Migrate generic unit/launcher to generated input with fail-closed digest validation | #758 | codex | stack | feat/fcm-launch-chain | FCM-M2-02 | 32K | One PR; FCM-AC-06; old `%i.env` cannot launch v2 |
|
||||||
|
| FCM-M2-04 | not-started | Implement generation-guarded atomic fleet-agent create/get/list/update/delete and compatibility aliases | #758 | codex | stack | feat/fcm-atomic-crud | FCM-M2-03 | 38K | One PR; FCM-AC-07, FCM-AC-12; create defaults stopped; no apply engine |
|
||||||
|
| FCM-M2-DOC | not-started | Publish generated-env chain, quarantine, and CRUD operator/developer guides | #758 | haiku | stack | docs/fcm-m2-projection-crud | FCM-M2-02, FCM-M2-03, FCM-M2-04 | 16K | One PR; FCM-AC-14; synthetic values only |
|
||||||
|
| FCM-M2-ROR | not-started | Independent correctness review of M2 projection and CRUD PRs | #758 | sonnet | stack | — | FCM-M2-01, FCM-M2-02, FCM-M2-03, FCM-M2-04, FCM-M2-DOC | 14K | Crash/concurrency/idempotency/permissions review |
|
||||||
|
| FCM-M2-SEC | not-started | Independent security review of launch chain, overrides, quarantine, paths, and diagnostics | #758 | sonnet | stack | — | FCM-M2-01, FCM-M2-02, FCM-M2-03, FCM-M2-04 | 16K | Adversarial shell/systemd/tmux/path/secret tests; FCM-AC-06–07 |
|
||||||
|
| FCM-M2-VAL | not-started | Validator certificate for M2 safe-projection/CRUD exit | #758 | sonnet | stack | — | FCM-M2-ROR, FCM-M2-SEC | 8K | Prove no hidden launch authority or surprise starts |
|
||||||
|
| FCM-M2-MERGE | not-started | Merge-gate approval for M2 completion | #758 | haiku | stack | — | FCM-M2-VAL | 4K | All M2 PRs merged and terminal-green |
|
||||||
|
| FCM-M3-01 | not-started | Implement locked local-only config apply with ordered convergence and machine-readable recovery | #758 | codex | stack | feat/fcm-local-apply | FCM-M2-MERGE | 40K | One PR; FCM-AC-08–10; zero calls for remote/unmanaged entries |
|
||||||
|
| FCM-M3-02 | not-started | Implement transient/persisted start, stop, restart, and fleet-wide lifecycle transitions | #758 | codex | stack | feat/fcm-lifecycle | FCM-M3-01 | 36K | One PR; FCM-AC-08–09; exact socket targeting |
|
||||||
|
| FCM-M3-03 | not-started | Implement status, verify, and doctor desired/observed/generation/drift/readiness contracts | #758 | codex | stack | feat/fcm-status-doctor | FCM-M3-01 | 30K | One PR; FCM-AC-09–10; safe effective output only |
|
||||||
|
| FCM-M3-04 | not-started | Add failure-injection, reboot/linger, unmanaged ownership, socket, and rollback integration suite | #758 | codex | stack | test/fcm-lifecycle-recovery | FCM-M3-02, FCM-M3-03 | 32K | One PR; FCM-AC-08–10 |
|
||||||
|
| FCM-M3-DOC | not-started | Publish CLI, lifecycle, status/drift, reconcile/recover, and systemd/tmux troubleshooting docs | #758 | haiku | stack | docs/fcm-m3-operations | FCM-M3-02, FCM-M3-03, FCM-M3-04 | 18K | One PR; FCM-AC-14 |
|
||||||
|
| FCM-M3-ROR | not-started | Independent correctness review of M3 lifecycle/recovery PRs | #758 | sonnet | stack | — | FCM-M3-01, FCM-M3-02, FCM-M3-03, FCM-M3-04, FCM-M3-DOC | 16K | Exact targeting, state transitions, recovery ordering |
|
||||||
|
| FCM-M3-SEC | not-started | Independent security review of apply/lifecycle authority and unmanaged-resource protection | #758 | sonnet | stack | — | FCM-M3-01, FCM-M3-02, FCM-M3-03, FCM-M3-04 | 16K | Policy denial, injection, TOCTOU, no-value output |
|
||||||
|
| FCM-M3-VAL | not-started | Validator certificate for M3 local lifecycle exit | #758 | sonnet | stack | — | FCM-M3-ROR, FCM-M3-SEC | 10K | Certify full transition table and FCM-AC-08–10 |
|
||||||
|
| FCM-M3-MERGE | not-started | Merge-gate approval for M3 completion | #758 | haiku | stack | — | FCM-M3-VAL | 4K | All M3 PRs merged and terminal-green |
|
||||||
|
| FCM-M4-01 | not-started | Implement field-complete v1 inventory, preview, aliases, unsupported-field reporting, and v2 writer | #758 | codex | stack | feat/fcm-v1-migrator | FCM-M3-MERGE | 38K | One PR; FCM-AC-11–12; no mutation without `--write` |
|
||||||
|
| FCM-M4-02 | not-started | Implement observed-state preservation, canary cutover, orphan classification, and reversible rollback | #758 | codex | stack | feat/fcm-migration-cutover | FCM-M4-01 | 40K | One PR; FCM-AC-11; unknown state blocks; stopped stays stopped |
|
||||||
|
| FCM-M4-03 | not-started | Add synthetic 9-managed/3-unmanaged migration, env quarantine, upgrade, and rollback E2E fixtures | #758 | codex | stack | test/fcm-migration-e2e | FCM-M4-02 | 34K | One PR; FCM-AC-11–12; no real credential/live-host data |
|
||||||
|
| FCM-M4-DOC | not-started | Publish v1→v2 field map, aliases, example disposition, backup/restore, and migration runbook | #758 | haiku | stack | docs/fcm-m4-migration | FCM-M4-01, FCM-M4-02, FCM-M4-03 | 18K | One PR; FCM-AC-14 |
|
||||||
|
| FCM-M4-ROR | not-started | Independent correctness review of M4 migration/cutover PRs | #758 | sonnet | stack | — | FCM-M4-01, FCM-M4-02, FCM-M4-03, FCM-M4-DOC | 16K | Field completeness, state preservation, rollback fidelity |
|
||||||
|
| FCM-M4-SEC | not-started | Independent security review of migration inventory, quarantine, and cutover | #758 | sonnet | stack | — | FCM-M4-01, FCM-M4-02, FCM-M4-03 | 16K | Secret-safe reporting and non-destructive ownership proof |
|
||||||
|
| FCM-M4-VAL | not-started | Validator certificate for M4 compatibility/migration exit | #758 | sonnet | stack | — | FCM-M4-ROR, FCM-M4-SEC | 10K | Certify FCM-AC-11–12 and rollback evidence |
|
||||||
|
| FCM-M4-MERGE | not-started | Merge-gate approval for M4 completion | #758 | haiku | stack | — | FCM-M4-VAL | 4K | All M4 PRs merged and terminal-green |
|
||||||
|
| FCM-M5-01 | not-started | Complete fleet documentation IA, sitemap, validated examples, and checklist evidence | #758 | haiku | stack | docs/fcm-complete-ia | FCM-M4-MERGE | 28K | One PR; FCM-AC-14; no required checklist item incomplete |
|
||||||
|
| FCM-M5-02 | not-started | Add package/install/update asset-drift and site-owned-state preservation qualification | #758 | codex | stack | test/fcm-package-upgrade | FCM-M4-MERGE | 32K | One PR; FCM-AC-13 |
|
||||||
|
| FCM-M5-03 | not-started | Run clean-home install, cold-start, local canary, rolling restart, failure, and rollback qualification | #758 | codex | stack | test/fcm-dogfood-qualification | FCM-M5-01, FCM-M5-02 | 30K | One PR; FCM-AC-08, FCM-AC-11, FCM-AC-13; synthetic harness/evidence only; never mutate production fleet |
|
||||||
|
| FCM-M5-ROR | not-started | Independent final correctness and documentation review | #758 | sonnet | stack | — | FCM-M5-01, FCM-M5-02, FCM-M5-03 | 16K | Verify FCM-AC-01–14 evidence and docs links/examples |
|
||||||
|
| FCM-M5-SEC | not-started | Independent final security review and threat-gate closure | #758 | sonnet | stack | — | FCM-M5-01, FCM-M5-02, FCM-M5-03 | 18K | Review launch/migration/lifecycle authority, secret handling, recovery |
|
||||||
|
| FCM-M5-VAL | not-started | Ultron/validator final acceptance certificate | #758 | sonnet | stack | — | FCM-M5-ROR, FCM-M5-SEC | 12K | Independent certificate for FCM-AC-01–15; no merge authority |
|
||||||
|
| FCM-M5-MERGE | not-started | Merge-gate final approve-to-land, terminal CI verification, issue closure, and release handoff | #758 | haiku | stack | — | FCM-M5-VAL | 6K | Sole merge path; FCM-AC-15; squash merge and close #758 after green CI |
|
||||||
|
|
||||||
|
### W4 acceptance mapping check
|
||||||
|
|
||||||
|
Every delivery card maps to at least one `FCM-AC-*` criterion in its notes. Gate cards verify those mappings rather than introducing implementation. The detailed documentation checklist is [`docs/scratchpads/758-fleet-config-docs-ia-checklist.md`](scratchpads/758-fleet-config-docs-ia-checklist.md); the shipped artifact inventory is [`docs/tasks/758-legacy-example-profile-disposition.md`](tasks/758-legacy-example-profile-disposition.md).
|
||||||
|
|||||||
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.
|
||||||
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.
|
||||||
261
docs/native-kanban-sot/TASKS.md
Normal file
261
docs/native-kanban-sot/TASKS.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
- **Owner:** Mos / publication control plane.
|
||||||
|
- **Mode:** SERIAL; publication gate in progress.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### KBN-010 — Threat, authorization, and constraint-impact gate
|
||||||
|
|
||||||
|
- **Owner:** coder3; independent `secrev`.
|
||||||
|
- **Mode:** SERIAL prerequisite of KBN-100.
|
||||||
|
- **Exclusive files:** Mos-selected threat/auth docs only.
|
||||||
|
- **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage.
|
||||||
|
- **OUT:** Runtime/schema edits.
|
||||||
|
- **Depends on:** KBN-000 independent re-review GO.
|
||||||
|
- **Contract surfaces:** schema constraints, health proof, exact errors, command-family authorization.
|
||||||
|
- **Evidence:** signed constraint-impact matrix; no unresolved schema-impact finding; SecReview pass.
|
||||||
|
|
||||||
|
### KBN-100 — Unified Drizzle schema and concrete N-1 migration
|
||||||
|
|
||||||
|
- **Owner:** **coder2**.
|
||||||
|
- **Mode:** SERIAL.
|
||||||
|
- **Exclusive files:** `packages/db/src/schema.ts`, `packages/db/drizzle/**`, DB tests.
|
||||||
|
- **IN:** All frozen tables/joins/enums; workspace/project-congruent constraints; owners/principals; tags/archive; change proposals with both workspace-aware task-event composite FKs and frozen event-before-proposal DDL order; assignment approvals; durable execution/quarantine; monotonic bigint fence; exact checkpoint/evidence joins; RESTRICT/immutability; concrete current-main expand/backfill/switch/contract map.
|
||||||
|
- **OUT:** Repositories, Gateway, Coordinator behavior, UI, importer.
|
||||||
|
- **Depends on:** **KBN-010 completed**.
|
||||||
|
- **Contract surfaces:** `kanban-schema.v1.ts`; SHARED-CONTRACT current-main delta map.
|
||||||
|
- **Evidence:** reviewed SQL; empty/prod-shape/partial-resume/rollback tests; N-1 app safety; legacy columns remain declared; workspace/project mismatch negatives; proposal event-FK missing/foreign-workspace tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; SecReview.
|
||||||
|
|
||||||
|
### KBN-105 — Exact Gateway/MCP endpoint, DTO, and error freeze
|
||||||
|
|
||||||
|
- **Owner:** Mos + coder3 contract author; independent endpoint-alignment reviewer.
|
||||||
|
- **Mode:** SERIAL after KBN-100; prerequisite for KBN-110/120/130.
|
||||||
|
- **Exclusive files:** canonical endpoint-registry/DTO contract docs; no implementation.
|
||||||
|
- **IN:** Exact routes and methods from SHARED-CONTRACT §8; request/success/error fields; status codes; pagination/filter/revision envelopes; idempotency/expected-version headers/fields; proposal commands; health proof exclusion from public DTOs; MCP tool-to-route map.
|
||||||
|
- **OUT:** Controller/service/client implementation.
|
||||||
|
- **Depends on:** KBN-100.
|
||||||
|
- **Contract surfaces:** health/error unions; schema IDs/statuses; Gateway DTO freeze.
|
||||||
|
- **Evidence:** every FE/CLI/MCP call maps 1:1 to a route; 503/502-504/409 non-cross-map fixtures; contract digest published.
|
||||||
|
|
||||||
|
### KBN-115 — Recovery posture parser, mechanisms, and evidence
|
||||||
|
|
||||||
|
- **Owner:** **coder2**, lane-serial after KBN-100.
|
||||||
|
- **Mode:** PARALLEL with KBN-110/120/130 after KBN-105.
|
||||||
|
- **Exclusive files:** `packages/config/src/recovery-posture.ts`, `.spec.ts`, `docs/runbooks/kanban-postgres-recovery.md`; deployment-specific backup manifest changes are a separately enumerated Mos integration patch.
|
||||||
|
- **IN:** Wire normative `validateRecoveryPostureV1`; override audit; backup/WAL/PITR mechanism assertions; off-cluster encryption/failure-domain checks; restore and break-glass evidence procedure.
|
||||||
|
- **OUT:** SOT/gate/Coordinator policy knobs; DB business schema.
|
||||||
|
- **Depends on:** KBN-100, KBN-105.
|
||||||
|
- **Contract surfaces:** `recovery-posture.v1.ts` only.
|
||||||
|
- **Evidence:** impossible-combination tests; High-assurance weakening tests; selected-tier mechanism verification; restore and break-glass evidence; SecReview.
|
||||||
|
|
||||||
|
## 5. P1 — Thin native MVP
|
||||||
|
|
||||||
|
### KBN-110 — Workspace-safe domain, Gateway, MCP server, and proposal commands
|
||||||
|
|
||||||
|
- **Owner:** **coder3**.
|
||||||
|
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
|
||||||
|
- **Exclusive files:** ownership map, including all exact MCP server files listed there.
|
||||||
|
- **IN:** Workspace-safe repositories; project/task/dependency/tag/archive CRUD; transitions; exact owners; assignment/approval/link/artifact queries; submit/query/accept/reject change proposals; health endpoint; internal write-proof mint/revalidation; event/outbox atomicity; frozen DTOs/routes.
|
||||||
|
- **OUT:** Scheduling algorithm, web, CLI, DB schema.
|
||||||
|
- **Depends on:** KBN-100, KBN-105.
|
||||||
|
- **Contract surfaces:** all four TypeScript contracts and exact registry.
|
||||||
|
- **Evidence:** DTO/service/controller/integration tests; active-membership and no-oracle negatives; proposal cannot mutate directly; submission event is the new proposal's exact `change_proposal.submitted` event; acceptance links the executed normal command for the locked proposal and same workspace/target; missing, foreign-workspace, unrelated-proposal/target/command event negatives; exact failure mapping; endpoint registry; SecReview.
|
||||||
|
|
||||||
|
### KBN-120 — CLI, MCP client mapping, and generated projection
|
||||||
|
|
||||||
|
- **Owner:** **coder4**; first coder4 slice.
|
||||||
|
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
|
||||||
|
- **Exclusive files:** `packages/mosaic/src/commands/{kanban,tasks,projects}.ts`; `packages/mosaic/src/projections/**`; tests. **No `apps/gateway/src/mcp/**` edits.\*\*
|
||||||
|
- **IN:** Frozen query/mutation routes; proposal commands; compact context; generated `TASKS.md`; deliberate denial/transport/conflict handling.
|
||||||
|
- **OUT:** Gateway/MCP server, file importer, raw SQL/Valkey, Coordinator.
|
||||||
|
- **Depends on:** KBN-105; runtime integration later requires KBN-110.
|
||||||
|
- **Evidence:** contract fixtures; same revision; no import parser; same idempotency key on transport retry; 503 never auto-retried.
|
||||||
|
|
||||||
|
### KBN-130 — Writable Kanban/List and minimal Projects UI
|
||||||
|
|
||||||
|
- **Owner:** **coder5**.
|
||||||
|
- **Mode:** PARALLEL-GROUP P1-A after KBN-105.
|
||||||
|
- **Exclusive files:** web ownership map.
|
||||||
|
- **IN:** Workspace context; projects; tasks; tags; explicit archive; detail; accessible move/reorder; filters; dependency/readiness; owner/assignment/lease; audit; proposal visibility; conflict/loading/error/reconnect.
|
||||||
|
- **OUT:** Gateway/schema, Coordinator operations UI, migration UI.
|
||||||
|
- **Depends on:** KBN-105; runtime integration later requires KBN-110.
|
||||||
|
- **Evidence:** frozen contract mocks; real-Gateway journeys; keyboard/non-drag; tags/archive semantics; no-oracle tenant negatives; 503/transport/409 distinct UI.
|
||||||
|
|
||||||
|
### KBN-140 — P1 integration and situational gate
|
||||||
|
|
||||||
|
- **Owner:** Mos integration; independent reviewer/SecReview/Certifier.
|
||||||
|
- **Mode:** INTEGRATION-SERIAL.
|
||||||
|
- **IN:** KBN-110/120/130/115; unavoidable root exports only.
|
||||||
|
- **OUT:** P2 behavior.
|
||||||
|
- **Depends on:** KBN-110, KBN-120, KBN-130, KBN-115.
|
||||||
|
- **Evidence:** clean migration; web/CLI/MCP/projection revision parity; forged/expired health negatives; change-proposal event-chain success plus missing/foreign/unrelated-event negatives; tag/archive; tenant negatives; endpoint registry; author-independent review; Certifier pass.
|
||||||
|
|
||||||
|
## 6. P2 — Mechanical Coordinator
|
||||||
|
|
||||||
|
### KBN-200 — Pure deterministic decision engine
|
||||||
|
|
||||||
|
- **Owner:** **coder4**; second coder4 slice, strictly after KBN-120.
|
||||||
|
- **Mode:** SERIAL in coder4 lane.
|
||||||
|
- **Exclusive files:** `packages/coord/src/mechanical/**` and pure tests.
|
||||||
|
- **IN:** `MechanicalCoordinatorDecisionEngineV1`; complete immutable snapshots; eligibility/explanation; fairness/order; capability matching; expiry/retry/quarantine decisions.
|
||||||
|
- **OUT:** ID loading, PostgreSQL, Drizzle, Gateway, Valkey, health-proof minting, persistence, `recoverFromPostgres`, LLM calls.
|
||||||
|
- **Depends on:** KBN-140 (or Mos may release after KBN-120 + frozen types if no P1 semantic risk remains).
|
||||||
|
- **Evidence:** deterministic/property tests; snapshot completeness; no I/O/model imports; no authority methods.
|
||||||
|
|
||||||
|
### KBN-210 — Coordinator persistence/service adapter and approval-bound leases
|
||||||
|
|
||||||
|
- **Owner:** **coder3**.
|
||||||
|
- **Mode:** SERIAL after KBN-200.
|
||||||
|
- **Exclusive files:** Gateway `coord` and repositories.
|
||||||
|
- **IN:** `MechanicalCoordinatorServicePortV1`; snapshot loading; proposal persistence; manual/versioned policy approval; acquire by IDs; reload+lock task/assignment/approval/session; fresh txn-local write proof; atomic task fence increment; lease/ack/heartbeat/checkpoint/submit; durable retry/quarantine; outbox/Valkey wake; restart recovery.
|
||||||
|
- **OUT:** Pure algorithm, UI, DB schema.
|
||||||
|
- **Depends on:** KBN-110, KBN-200.
|
||||||
|
- **Evidence:** forged/stale approval rejection; target/session/version/expiry/policy checks; concurrent monotonic fences; same-workspace mismatch negatives; bigint precision; stale worker rejection; DB/Valkey faults; SecReview.
|
||||||
|
|
||||||
|
### KBN-220 — Coordinator operations UI
|
||||||
|
|
||||||
|
- **Owner:** **coder5**.
|
||||||
|
- **Mode:** after KBN-210 exact DTO freeze.
|
||||||
|
- **IN:** Roster; eligibility; persisted assignment state; approvals/overrides; exact lease/fence; durable retry/quarantine; role/gate/Certifier visibility.
|
||||||
|
- **OUT:** Scheduling decisions, schema, merge control for Certifier.
|
||||||
|
- **Depends on:** KBN-210.
|
||||||
|
- **Evidence:** authorized journeys; reason required; stale refresh; no Certifier merge; endpoint alignment/accessibility.
|
||||||
|
|
||||||
|
### KBN-230 — P2 concurrency/fault/gate integration
|
||||||
|
|
||||||
|
- **Owner:** Mos integration; independent reviewer/SecReview/Certifier.
|
||||||
|
- **Mode:** INTEGRATION-SERIAL.
|
||||||
|
- **Depends on:** KBN-200, KBN-210, KBN-220.
|
||||||
|
- **Evidence:** one lease; monotonic fences; exact relational mismatches rejected; expired proof; forged healthy; approval binding; restart; durable quarantine; outbox recovery; author≠reviewer; Certifier final/no merge.
|
||||||
|
|
||||||
|
## 7. P3 — Shadow migration and cutover
|
||||||
|
|
||||||
|
### KBN-300 — One-way importer dry-run/apply/verify
|
||||||
|
|
||||||
|
- **Owner:** **coder4**; third coder4 slice.
|
||||||
|
- **Mode:** after KBN-230.
|
||||||
|
- **Exclusive files:** `scripts/kanban-migration/import/**`.
|
||||||
|
- **IN:** Immutable jarvis-brain/Vikunja snapshots; deterministic mapping; source digest/lineage; Gateway writes; rejects; no dispatch.
|
||||||
|
- **OUT:** Bidirectional sync, direct DB/file canonical writes, unrelated brain data.
|
||||||
|
- **Depends on:** KBN-230.
|
||||||
|
- **Evidence:** idempotency; counts/fields; malformed/foreign rejects; no dispatch; SecReview.
|
||||||
|
|
||||||
|
### KBN-310 — Shadow reviewer UI
|
||||||
|
|
||||||
|
- **Owner:** **coder5**.
|
||||||
|
- **Mode:** PARALLEL-GROUP P3-A after KBN-300 report freeze.
|
||||||
|
- **IN:** Read-only counts/diffs/rejects/lineage/sign-off.
|
||||||
|
- **OUT:** Apply/cutover mutations.
|
||||||
|
- **Depends on:** KBN-300.
|
||||||
|
- **Evidence:** read-only and tenant tests; pagination/accessibility.
|
||||||
|
|
||||||
|
### KBN-320 — Cutover/rollback tooling
|
||||||
|
|
||||||
|
- **Owner:** **coder4**; fourth coder4 slice, after KBN-300.
|
||||||
|
- **Mode:** PARALLEL-GROUP P3-A with KBN-310.
|
||||||
|
- **Exclusive files:** `scripts/kanban-migration/cutover/**`.
|
||||||
|
- **IN:** Freeze assertion; backup/checksum; final delta; client switch; legacy writer/credential shutdown; rollback delta; stabilization.
|
||||||
|
- **OUT:** Destructive deletion, reverse sync, ungated production execution.
|
||||||
|
- **Depends on:** KBN-300.
|
||||||
|
- **Evidence:** fail-safe rehearsal; no dual writer; rollback authority; SecReview.
|
||||||
|
|
||||||
|
### KBN-330 — Migration rehearsal/reconciliation
|
||||||
|
|
||||||
|
- **Owner:** Mos + coder4 support + independent data reviewer.
|
||||||
|
- **Mode:** INTEGRATION-SERIAL.
|
||||||
|
- **Depends on:** KBN-310, KBN-320.
|
||||||
|
- **Evidence:** signed exceptions; selected-tier restore; backlog hold; no legacy changes; Certifier readiness.
|
||||||
|
|
||||||
|
### KBN-340 — Final cutover/stabilization
|
||||||
|
|
||||||
|
- **Owner:** Mos/control plane; owner-gated operation.
|
||||||
|
- **Mode:** SERIAL.
|
||||||
|
- **Depends on:** KBN-330 PASS and Jason authorization.
|
||||||
|
- **Evidence:** no legacy writer; scoped Gateway identities; no accidental dispatch; terminal green health/CI; Certifier evidence; owner retirement approval.
|
||||||
|
|
||||||
|
## 8. Consistent USC wave schedule
|
||||||
|
|
||||||
|
| Wave | coder2 | coder3 | coder4 | coder5 |
|
||||||
|
| ---- | ------------------------- | -------------------------------------- | ------------------------------ | ------------------------------ |
|
||||||
|
| 0 | Wait | **KBN-010** | Wait | Wait |
|
||||||
|
| 1 | **KBN-100** | Review constraint implementation | Wait | Wait |
|
||||||
|
| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 |
|
||||||
|
| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 |
|
||||||
|
| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** |
|
||||||
|
| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** |
|
||||||
|
|
||||||
|
Mos alone releases slices and lifts the build hold after independent re-review GO.
|
||||||
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,
|
summary: Submit Mos handoff,
|
||||||
parameters: [{ $ref: '#/components/parameters/correlation' }],
|
parameters: [{ $ref: '#/components/parameters/correlation' }],
|
||||||
requestBody: { $ref: '#/components/requestBodies/MosHandoff' },
|
requestBody: { $ref: '#/components/requestBodies/Handoff' },
|
||||||
responses: { '200': { description: Receipt } },
|
responses: { '200': { description: Receipt } },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ components:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MosHandoff:
|
Handoff:
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
content:
|
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.
|
||||||
48
docs/scratchpads/758-fleet-config-docs-ia-checklist.md
Normal file
48
docs/scratchpads/758-fleet-config-docs-ia-checklist.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# #758 Fleet configuration documentation IA acceptance checklist
|
||||||
|
|
||||||
|
**Scope:** M0 planning gate for issue #758. This checklist defines required documentation outcomes; it does not authorize source, schema, role, example, systemd, or live-fleet changes.
|
||||||
|
|
||||||
|
## Acceptance states
|
||||||
|
|
||||||
|
Use `required`, `deferred`, or `complete`. A deferred item requires a target milestone and rationale. M5 cannot exit with a required item incomplete.
|
||||||
|
|
||||||
|
| Path | Purpose | Owner milestone | Required evidence | State |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `docs/PRD.md` | Normative requirements, scope, authority, lifecycle, migration, risks, and acceptance criteria | M0 | Approved requirements PR linked to #758 | required |
|
||||||
|
| `docs/TASKS.md` | M0–M5 one-card/one-PR DAG and review gates | M0 | Every delivery card maps to acceptance criteria and dependencies | required |
|
||||||
|
| `docs/fleet/README.md` | Fleet configuration entry point and operator decision tree | M5 | Links all accepted fleet-config pages and passes link validation | required |
|
||||||
|
| `docs/fleet/concepts/desired-vs-observed-state.md` | Desired, generated, and observed state boundaries | M5 | Matches lifecycle contract and status JSON | required |
|
||||||
|
| `docs/fleet/concepts/identity-class-runtime.md` | Stable identity, alias, class, runtime/provider/model policy | M5 | Matches executable schema and shared role resolver | required |
|
||||||
|
| `docs/fleet/concepts/role-authority-and-leases.md` | Authority matrix and bounded capacity leases | M1 | Independently reviewed against role contracts | required |
|
||||||
|
| `docs/fleet/concepts/generated-env-launch-chain.md` | `.env.generated`, `.env.local`, quarantine, and unit/launcher chain | M2 | Security review and launch-chain tests linked | required |
|
||||||
|
| `docs/fleet/reference/roster-v2.schema.json` | Published executable schema artifact | M1 | Schema/parser parity suite consumes this artifact | required |
|
||||||
|
| `docs/fleet/reference/roster-v2-fields.md` | Every field, default, constraint, compatibility rule, and example | M1 | Field-by-field parity review | required |
|
||||||
|
| `docs/fleet/reference/cli.md` | Config, CRUD, lifecycle commands, JSON shapes, and exit codes | M3 | CLI contract tests and examples linked | required |
|
||||||
|
| `docs/fleet/reference/role-classes.md` | Required/optional classes, aliases, and powers | M1 | Existing resolver validates every documented class | required |
|
||||||
|
| `docs/fleet/reference/lifecycle-transitions.md` | Complete enabled/desired/observed transition table | M3 | Lifecycle and reboot tests linked | required |
|
||||||
|
| `docs/fleet/reference/status-and-drift.md` | Drift planes, readiness, ownership proof, and safe output | M3 | Golden JSON/status tests linked | required |
|
||||||
|
| `docs/fleet/how-to/create-update-delete-agent.md` | Safe CRUD and dry-run workflows | M2 | Fresh and idempotent examples validate | required |
|
||||||
|
| `docs/fleet/how-to/start-stop-restart.md` | Transient versus persisted lifecycle operations | M3 | Matches transition tests | required |
|
||||||
|
| `docs/fleet/how-to/configure-tess-interaction.md` | Configurable interaction instance, not hardcoded identity | M5 | Example validates through shared resolver | required |
|
||||||
|
| `docs/fleet/how-to/configure-ultron-validator.md` | Validator instance without merge authority | M5 | Example validates and authority review passes | required |
|
||||||
|
| `docs/fleet/how-to/customize-roles.md` | Baseline plus `roles.local` resolution and policy | M1 | No parallel resolver described or implemented | required |
|
||||||
|
| `docs/fleet/operations/reconcile-and-recover.md` | Plan/apply, partial failure, recovery, and rollback | M3 | Failure-injection evidence linked | required |
|
||||||
|
| `docs/fleet/operations/env-quarantine.md` | Legacy key inventory and secret-safe disposition | M2 | Security tests prove no values are emitted | required |
|
||||||
|
| `docs/fleet/operations/systemd-tmux-troubleshooting.md` | Exact local targeting and non-destructive diagnostics | M3 | Named/default socket and unmanaged-session tests linked | required |
|
||||||
|
| `docs/fleet/operations/backup-restore.md` | Generation backup and rollback | M4 | Migration rollback drill linked | required |
|
||||||
|
| `docs/fleet/operations/upgrade-assets.md` | Source/installed asset drift and update behavior | M5 | Package/update test linked | required |
|
||||||
|
| `docs/fleet/migration/v1-to-v2.md` | Normative field mapping and stopped-state preservation | M4 | Migration fixtures and canary evidence linked | required |
|
||||||
|
| `docs/fleet/migration/example-profile-disposition.md` | Disposition of every shipped example/profile | M1 | Inventory has no unresolved item at M1 exit | required |
|
||||||
|
| `docs/fleet/migration/legacy-class-aliases.md` | Deterministic aliases and manual-review classes | M1 | Alias/unknown-role tests linked | required |
|
||||||
|
| `docs/SITEMAP.md` | Navigation index | M5 | Link checker passes with all required pages | required |
|
||||||
|
|
||||||
|
## Cross-cutting acceptance checks
|
||||||
|
|
||||||
|
- [ ] Every documented command has stable text and JSON behavior plus an exit-code contract.
|
||||||
|
- [ ] Every documented schema field is accepted by the executable validator, and every accepted field is documented.
|
||||||
|
- [ ] Examples use synthetic non-secret values only.
|
||||||
|
- [ ] Generated files are explicitly labeled non-authoritative and rebuildable.
|
||||||
|
- [ ] Gateway-backed `mosaic agent` records are documented as a separate control plane; no implicit convergence is promised.
|
||||||
|
- [ ] Local M1–M5 scope excludes remote/SSH reconciliation, connector mutation, secret-reference schema, arbitrary commands/channels, and UI/gateway config convergence.
|
||||||
|
- [ ] Independent correctness, security, and validator evidence is linked before M5 acceptance.
|
||||||
|
- [ ] Documentation links and formatting pass repository gates.
|
||||||
@@ -18,12 +18,12 @@ Implement a transport-neutral coordination contract allowing a configured intera
|
|||||||
|
|
||||||
## Design checkpoint — 2026-07-12
|
## 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
|
## Progress checkpoint — 2026-07-13
|
||||||
|
|
||||||
- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
|
- Implemented `InteractionCoordinationPort` 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 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.
|
- 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`.
|
- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`.
|
||||||
|
|
||||||
|
|||||||
57
docs/tasks/758-legacy-example-profile-disposition.md
Normal file
57
docs/tasks/758-legacy-example-profile-disposition.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# #758 Legacy shipped example and profile disposition inventory
|
||||||
|
|
||||||
|
**Baseline:** `origin/main` at M0 planning time. This inventory is normative input to M1; M0 changes no examples or profiles.
|
||||||
|
|
||||||
|
## Allowed dispositions
|
||||||
|
|
||||||
|
1. **Migrate:** rewrite as v2 and validate with the executable schema plus the existing baseline/`roles.local` resolver.
|
||||||
|
2. **Compatibility fixture:** retain as explicitly versioned v1 input for migration tests; it must not be advertised as current authoring guidance.
|
||||||
|
3. **Retire:** remove only in its own implementation PR with a replacement and deprecation note.
|
||||||
|
|
||||||
|
No item may remain `decision-required` when M1 exits.
|
||||||
|
|
||||||
|
## Fleet examples
|
||||||
|
|
||||||
|
| Shipped path | M0 observed concern | Planned disposition | Target card | Required evidence |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------- |
|
||||||
|
| `packages/mosaic/framework/fleet/examples/minimal.yaml` | Uses unresolved legacy `canary`; v1 field form also requires migration | decision-required: map through existing resolver, retain as compatibility fixture, or retire with replacement | FCM-M1-04 | Explicit M1 disposition plus v2 schema/shared-resolver CI |
|
||||||
|
| `packages/mosaic/framework/fleet/examples/coding.yaml` | Legacy `implementer`/`reviewer` aliases may be present | migrate; preserve alias cases separately in migration fixtures | FCM-M1-04 | Alias conversion and current v2 validation |
|
||||||
|
| `packages/mosaic/framework/fleet/examples/general.yaml` | Legacy/general classes require authority review | migrate or retire unsupported roles with replacement | FCM-M1-04 | No unresolved class |
|
||||||
|
| `packages/mosaic/framework/fleet/examples/hybrid.yaml` | Mixed runtime/provider capability combinations | migrate | FCM-M1-04 | Runtime-capability and resolver validation |
|
||||||
|
| `packages/mosaic/framework/fleet/examples/local-canary.yaml` | Local lifecycle and socket semantics | migrate | FCM-M1-04 | Default/named socket and stopped-state fixtures |
|
||||||
|
| `packages/mosaic/framework/fleet/examples/operator-interaction.yaml` | `operator-interaction` becomes `interaction`; Tess remains instance data | migrate | FCM-M1-04 | Alias test and interaction role resolution |
|
||||||
|
| `packages/mosaic/framework/fleet/examples/research.yaml` | Legacy `analyst` may lack canonical contract | decision-required: map through existing resolver or retire | FCM-M1-04 | Explicit M1 disposition; no silent alias |
|
||||||
|
|
||||||
|
## System-type profiles
|
||||||
|
|
||||||
|
| Shipped path | M0 observed concern | Planned disposition | Target card | Required evidence |
|
||||||
|
| ------------------------------------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------- | ----------- | -------------------------------------------- |
|
||||||
|
| `packages/mosaic/framework/fleet/profiles/software-delivery.yaml` | Must represent required governance seats or explicit waivers | migrate | FCM-M1-04 | Full-profile topology validation |
|
||||||
|
| `packages/mosaic/framework/fleet/profiles/business.yaml` | Classes must resolve through shared resolver | migrate or document waived-class policy | FCM-M1-04 | Profile validation parity |
|
||||||
|
| `packages/mosaic/framework/fleet/profiles/marketing.yaml` | Classes must resolve through shared resolver | migrate or document waived-class policy | FCM-M1-04 | Profile validation parity |
|
||||||
|
| `packages/mosaic/framework/fleet/profiles/personal-assistant.yaml` | Interaction/orchestration authority must remain bounded | migrate | FCM-M1-04 | Authority and topology tests |
|
||||||
|
| `packages/mosaic/framework/fleet/profiles/research.yaml` | Potential legacy analyst/worker class ambiguity | decision-required: resolved local role or retirement | FCM-M1-04 | Explicit M1 disposition; no unresolved class |
|
||||||
|
|
||||||
|
## Shipped fleet service presets
|
||||||
|
|
||||||
|
| Shipped path | M0 observed concern | Planned disposition | Target card | Required evidence |
|
||||||
|
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------- |
|
||||||
|
| `packages/mosaic/framework/fleet/services/operator-interaction.yaml` | Uses legacy `operator-interaction` class/tool policy and launch hints; it participates in the interaction/Tess launch contract even though it is not an example/profile | migrate to canonical `interaction` through the shared resolver, preserving Tess as instance data; retain legacy alias coverage in a dedicated compatibility fixture | FCM-M1-04 | v2 schema, shared resolver, runtime-capability, tool-policy, and alias tests |
|
||||||
|
|
||||||
|
No other shipped file currently exists under `packages/mosaic/framework/fleet/services/`. Future service presets added before FCM-M1-04 must be inventoried under the same migrate/compatibility/retire rule.
|
||||||
|
|
||||||
|
## Required compatibility fixtures
|
||||||
|
|
||||||
|
M1/M4 may add dedicated test fixtures rather than retaining public examples in an obsolete form:
|
||||||
|
|
||||||
|
- v1 snake_case and camelCase equivalents;
|
||||||
|
- deterministic `implementer → code`, `reviewer → review`, and `operator-interaction → interaction` aliases;
|
||||||
|
- ambiguous `worker`, `analyst`, `canary`, and unknown classes that fail pending explicit resolution;
|
||||||
|
- schema-only `host`, `ssh`, `socket`, and top-level `connector` fields reported as unsupported for local v2 apply;
|
||||||
|
- the observed 9-roster/12-projection mismatch represented synthetically, with three unmanaged/orphan candidates;
|
||||||
|
- running, stopped/dead, and unknown observed-state migration cases;
|
||||||
|
- legacy generated, allowlisted local, forbidden command/channel/credential, and unknown env keys using names and synthetic hashes only—never values.
|
||||||
|
|
||||||
|
## Exit rule
|
||||||
|
|
||||||
|
FCM-M1-04 cannot close until every inventoried example, profile, and service preset is migrated, retained as a versioned compatibility fixture, or retired with documented replacement evidence. CI must validate all shipped YAML/JSON examples, profiles, and service presets through the same executable schema and shared profile/persona resolver used by roster load, migration, and apply.
|
||||||
@@ -54,7 +54,7 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa
|
|||||||
|
|
||||||
### Mos Coordination Boundary
|
### 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,
|
verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant,
|
||||||
correlation, and interaction-agent identity from authenticated context plus
|
correlation, and interaction-agent identity from authenticated context plus
|
||||||
trusted configuration; callers never provide an orchestration target. It
|
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.
|
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.
|
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.
|
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.
|
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.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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`).
|
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.
|
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.
|
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.
|
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
|
readonly requesterAgentId: string; // trusted gateway/configuration data
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MosHandoffRequest {
|
interface HandoffRequest {
|
||||||
readonly idempotencyKey: string;
|
readonly idempotencyKey: string;
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
readonly context?: string;
|
readonly context?: string;
|
||||||
readonly missionId?: string;
|
readonly missionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MosHandoffReceipt {
|
interface HandoffReceipt {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly status: 'accepted' | 'queued';
|
readonly status: 'accepted' | 'queued';
|
||||||
readonly correlationId: string;
|
readonly correlationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MosHandoff {
|
interface Handoff {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly request: MosHandoffRequest;
|
readonly request: HandoffRequest;
|
||||||
readonly scope: CoordinationScope;
|
readonly scope: CoordinationScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MosCoordinationPort {
|
interface InteractionCoordinationPort {
|
||||||
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
handoff(handoff: Handoff): Promise<HandoffReceipt>;
|
||||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
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`,
|
correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`,
|
||||||
and the requester agent from trusted authentication/configuration only.
|
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
|
## 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
|
trusted configured requester/target identities and rejects all of the following
|
||||||
before calling a transport: unconfigured requester, self-delegation, target
|
before calling a transport: unconfigured requester, self-delegation, target
|
||||||
identity drift, cross-tenant observe/result lookup, and attempts to observe or
|
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,
|
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.
|
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
|
deterministic adapter. It preserves the immutable handoff ID, tenant, requester
|
||||||
identity, and correlation ID while demonstrating the handoff → observe → result
|
identity, and correlation ID while demonstrating the handoff → observe → result
|
||||||
round trip. It is a queue/port adapter, not a Mos-side consumer.
|
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
|
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.
|
runtime calls a transport directly.
|
||||||
|
|
||||||
## Required tests
|
## 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.
|
6. Every externally caused operation is replay-safe and correlated.
|
||||||
7. Provider capability absence is a denial, not an invitation to shell around it.
|
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.
|
The original M1 findings below are closed by landed controls and retained for audit traceability.
|
||||||
- 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.
|
|
||||||
|
|
||||||
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,
|
DurableSessionCoordinator,
|
||||||
InMemoryDurableSessionStore,
|
InMemoryDurableSessionStore,
|
||||||
type DurableSessionIdentity,
|
type DurableSessionIdentity,
|
||||||
} from './tess-durable-session.js';
|
} from './durable-session.js';
|
||||||
|
|
||||||
const IDENTITY: DurableSessionIdentity = {
|
const IDENTITY: DurableSessionIdentity = {
|
||||||
agentName: 'Nova',
|
agentName: 'Nova',
|
||||||
@@ -102,7 +102,7 @@ export interface DurableSessionStore {
|
|||||||
|
|
||||||
export class DurableSessionNotFoundError extends Error {
|
export class DurableSessionNotFoundError extends Error {
|
||||||
constructor(sessionId: string) {
|
constructor(sessionId: string) {
|
||||||
super(`Durable Tess session not found: ${sessionId}`);
|
super(`Durable session not found: ${sessionId}`);
|
||||||
this.name = 'DurableSessionNotFoundError';
|
this.name = 'DurableSessionNotFoundError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,11 +212,11 @@ export class DurableSessionCoordinator {
|
|||||||
|
|
||||||
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
|
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
|
||||||
const handoff = await this.store.findHandoff(handoffId);
|
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 snapshot = await this.snapshot(handoff.sessionId);
|
||||||
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
|
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
|
||||||
if (!checkpoint) {
|
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 };
|
return { identity: snapshot.identity, checkpoint, handoff };
|
||||||
}
|
}
|
||||||
@@ -257,7 +257,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const existing = this.sessions.get(identity.sessionId);
|
const existing = this.sessions.get(identity.sessionId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!sameEnrollmentScope(existing.identity, identity)) {
|
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.providerId = identity.providerId;
|
||||||
existing.identity.runtimeSessionId = identity.runtimeSessionId;
|
existing.identity.runtimeSessionId = identity.runtimeSessionId;
|
||||||
@@ -290,7 +290,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const existing = state.inbox.get(input.idempotencyKey);
|
const existing = state.inbox.get(input.idempotencyKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!sameInbox(existing, input)) {
|
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 };
|
return { accepted: false, status: existing.status };
|
||||||
}
|
}
|
||||||
@@ -323,7 +323,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const existing = state.outbox.get(input.idempotencyKey);
|
const existing = state.outbox.get(input.idempotencyKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!sameOutbox(existing, input)) {
|
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 };
|
return { accepted: false, status: existing.status };
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const checkpoints = this.require(input.sessionId).checkpoints;
|
const checkpoints = this.require(input.sessionId).checkpoints;
|
||||||
const existing = checkpoints.get(input.checkpointId);
|
const existing = checkpoints.get(input.checkpointId);
|
||||||
if (existing && !sameCheckpoint(existing, input)) {
|
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 });
|
if (!existing) checkpoints.set(input.checkpointId, { ...input });
|
||||||
}
|
}
|
||||||
@@ -377,11 +377,11 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||||
const state = this.require(input.sessionId);
|
const state = this.require(input.sessionId);
|
||||||
if (!state.checkpoints.has(input.checkpointId)) {
|
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);
|
const existing = state.handoffs.get(input.handoffId);
|
||||||
if (existing && !sameHandoff(existing, input)) {
|
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 });
|
if (!existing) state.handoffs.set(input.handoffId, { ...input });
|
||||||
}
|
}
|
||||||
@@ -413,7 +413,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
kind: string,
|
kind: string,
|
||||||
): T {
|
): T {
|
||||||
const entry = records.get(idempotencyKey);
|
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;
|
return entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,4 +4,4 @@ export * from './runtime-provider-registry.js';
|
|||||||
export * from './tmux-fleet-runtime-provider.js';
|
export * from './tmux-fleet-runtime-provider.js';
|
||||||
export * from './hermes-runtime-provider.js';
|
export * from './hermes-runtime-provider.js';
|
||||||
export * from './matrix-native-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> {
|
async assertAuthorized(): Promise<void> {
|
||||||
throw new MatrixRuntimeProviderError(
|
throw new MatrixRuntimeProviderError(
|
||||||
'forbidden',
|
'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();
|
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();
|
const fleet = transport();
|
||||||
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
|
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
|
||||||
throw new Error('target identity mismatch');
|
throw new Error('target identity mismatch');
|
||||||
@@ -220,7 +220,20 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
expect(fleet.sendMessage).not.toHaveBeenCalled();
|
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 fleet = transport();
|
||||||
const writeAuthority: FleetWriteAuthority = {
|
const writeAuthority: FleetWriteAuthority = {
|
||||||
canWrite: vi.fn(async (): Promise<boolean> => true),
|
canWrite: vi.fn(async (): Promise<boolean> => true),
|
||||||
@@ -228,7 +241,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
};
|
};
|
||||||
const provider = new TmuxFleetRuntimeProvider({
|
const provider = new TmuxFleetRuntimeProvider({
|
||||||
transport: fleet,
|
transport: fleet,
|
||||||
sourceLabel: 'tess',
|
sourceLabel: 'operator',
|
||||||
writeAuthority,
|
writeAuthority,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,7 +259,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
scope,
|
scope,
|
||||||
approvalRef: 'approval-1',
|
approvalRef: 'approval-1',
|
||||||
});
|
});
|
||||||
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'tess');
|
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator');
|
||||||
expect(fleet.terminate).toHaveBeenCalledWith('coder0');
|
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
|
* 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 {
|
export interface FleetWriteAuthority {
|
||||||
/** Non-consuming preflight used before probing the fleet transport. */
|
/** Non-consuming preflight used before probing the fleet transport. */
|
||||||
canWrite(authorization: FleetWriteAuthorization): Promise<boolean>;
|
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>;
|
assertAuthorized(authorization: FleetWriteAuthorization): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority {
|
|||||||
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
|
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
|
||||||
throw new FleetRuntimeProviderError(
|
throw new FleetRuntimeProviderError(
|
||||||
'forbidden',
|
'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
|
* A capability-limited provider for rostered local fleet peers. It never
|
||||||
* permits raw tmux socket/target selection, interactive control attach, or
|
* 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 {
|
export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
||||||
readonly id = FLEET_PROVIDER_ID;
|
readonly id = FLEET_PROVIDER_ID;
|
||||||
@@ -139,7 +139,7 @@ export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
|||||||
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
|
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
|
||||||
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
|
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
|
||||||
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
|
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
|
||||||
this.sourceLabel = options.sourceLabel ?? 'tess';
|
this.sourceLabel = options.sourceLabel ?? 'interaction';
|
||||||
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
|
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
|
||||||
this.now = options.now ?? (() => new Date());
|
this.now = options.now ?? (() => new Date());
|
||||||
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
|
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
InMemoryMosCoordinationPort,
|
InMemoryInteractionCoordinationPort,
|
||||||
MosCoordinationClient,
|
InteractionCoordinationClient,
|
||||||
type CoordinationScope,
|
type CoordinationScope,
|
||||||
type MosCoordinationAuthorityError,
|
type InteractionCoordinationAuthorityError,
|
||||||
type MosCoordinationPort,
|
type InteractionCoordinationPort,
|
||||||
} from '../index.js';
|
} from '../index.js';
|
||||||
|
|
||||||
const scope: CoordinationScope = {
|
const scope: CoordinationScope = {
|
||||||
@@ -15,19 +15,19 @@ const scope: CoordinationScope = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function client(
|
function client(
|
||||||
port: MosCoordinationPort,
|
port: InteractionCoordinationPort,
|
||||||
handoffIdFactory: () => string = (): string => 'handoff-1',
|
handoffIdFactory: () => string = (): string => 'handoff-1',
|
||||||
): MosCoordinationClient {
|
): InteractionCoordinationClient {
|
||||||
return new MosCoordinationClient(
|
return new InteractionCoordinationClient(
|
||||||
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
|
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
|
||||||
port,
|
port,
|
||||||
handoffIdFactory,
|
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> => {
|
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);
|
const coordination = client(adapter);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -42,8 +42,8 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
correlationId: 'corr-1',
|
correlationId: 'corr-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
|
||||||
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
||||||
|
|
||||||
await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({
|
await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
@@ -59,7 +59,7 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
targetAgentId: 'Conductor',
|
targetAgentId: 'Conductor',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
correlationId: 'corr-1',
|
correlationId: 'corr-1',
|
||||||
summary: 'Merged by Mos',
|
summary: 'Merged by orchestrator',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(coordination).not.toHaveProperty('dispatch');
|
expect(coordination).not.toHaveProperty('dispatch');
|
||||||
@@ -69,8 +69,8 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
expect(coordination).not.toHaveProperty('cancel');
|
expect(coordination).not.toHaveProperty('cancel');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed before delivery when an unconfigured agent requests Mos work', async (): Promise<void> => {
|
it('fails closed before delivery when an unconfigured agent requests orchestrator work', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client(adapter).handoff(
|
client(adapter).handoff(
|
||||||
@@ -79,31 +79,31 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
),
|
),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'requester_forbidden',
|
code: 'requester_forbidden',
|
||||||
} satisfies Partial<MosCoordinationAuthorityError>);
|
} satisfies Partial<InteractionCoordinationAuthorityError>);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects self-delegation configuration before constructing a client', (): void => {
|
it('rejects self-delegation configuration before constructing a client', (): void => {
|
||||||
expect(
|
expect(
|
||||||
(): MosCoordinationClient =>
|
(): InteractionCoordinationClient =>
|
||||||
new MosCoordinationClient(
|
new InteractionCoordinationClient(
|
||||||
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||||
new InMemoryMosCoordinationPort(),
|
new InMemoryInteractionCoordinationPort(),
|
||||||
),
|
),
|
||||||
).toThrow('Interaction and orchestration identities must differ');
|
).toThrow('Interaction and orchestration identities must differ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects whitespace-equivalent self-delegation identities', (): void => {
|
it('rejects whitespace-equivalent self-delegation identities', (): void => {
|
||||||
expect(
|
expect(
|
||||||
(): MosCoordinationClient =>
|
(): InteractionCoordinationClient =>
|
||||||
new MosCoordinationClient(
|
new InteractionCoordinationClient(
|
||||||
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
|
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
|
||||||
new InMemoryMosCoordinationPort(),
|
new InMemoryInteractionCoordinationPort(),
|
||||||
),
|
),
|
||||||
).toThrow('Interaction and orchestration identities must differ');
|
).toThrow('Interaction and orchestration identities must differ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose another tenant handoff to observe or result', async (): Promise<void> => {
|
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);
|
const coordination = client(adapter);
|
||||||
await coordination.handoff(
|
await coordination.handoff(
|
||||||
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
{ 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> => {
|
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 first = client(adapter, (): string => 'handoff-1');
|
||||||
const second = client(adapter, (): string => 'handoff-2');
|
const second = client(adapter, (): string => 'handoff-2');
|
||||||
await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope);
|
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> => {
|
it('fails closed when a transport reports target drift', async (): Promise<void> => {
|
||||||
const adapter: MosCoordinationPort = {
|
const adapter: InteractionCoordinationPort = {
|
||||||
handoff: vi.fn(async () => ({
|
handoff: vi.fn(async () => ({
|
||||||
handoffId: 'handoff-1',
|
handoffId: 'handoff-1',
|
||||||
targetAgentId: 'Unexpected',
|
targetAgentId: 'Unexpected',
|
||||||
@@ -149,6 +149,6 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
),
|
),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'target_drift',
|
code: 'target_drift',
|
||||||
} satisfies Partial<MosCoordinationAuthorityError>);
|
} satisfies Partial<InteractionCoordinationAuthorityError>);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,25 +2,25 @@ import {
|
|||||||
type CoordinationObservation,
|
type CoordinationObservation,
|
||||||
type CoordinationResult,
|
type CoordinationResult,
|
||||||
type CoordinationScope,
|
type CoordinationScope,
|
||||||
type MosCoordinationActivity,
|
type InteractionCoordinationActivity,
|
||||||
type MosCoordinationPort,
|
type InteractionCoordinationPort,
|
||||||
type MosHandoff,
|
type Handoff,
|
||||||
type MosHandoffReceipt,
|
type HandoffReceipt,
|
||||||
type MosHandoffStatus,
|
type HandoffStatus,
|
||||||
} from './mos-coordination.js';
|
} from './interaction-coordination.js';
|
||||||
|
|
||||||
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
|
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
|
||||||
const DEFAULT_MAX_HANDOFFS = 1_000;
|
const DEFAULT_MAX_HANDOFFS = 1_000;
|
||||||
|
|
||||||
interface StoredHandoff {
|
interface StoredHandoff {
|
||||||
readonly handoff: MosHandoff;
|
readonly handoff: Handoff;
|
||||||
status: MosHandoffStatus;
|
status: HandoffStatus;
|
||||||
readonly activity: MosCoordinationActivity[];
|
readonly activity: InteractionCoordinationActivity[];
|
||||||
readonly expiresAt: number;
|
readonly expiresAt: number;
|
||||||
result?: CoordinationResult;
|
result?: CoordinationResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InMemoryMosCoordinationPortOptions {
|
export interface InMemoryInteractionCoordinationPortOptions {
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
handoffTtlMs?: number;
|
handoffTtlMs?: number;
|
||||||
maxHandoffs?: number;
|
maxHandoffs?: number;
|
||||||
@@ -29,21 +29,21 @@ export interface InMemoryMosCoordinationPortOptions {
|
|||||||
/**
|
/**
|
||||||
* Native deterministic queue/port adapter for the coordination boundary.
|
* Native deterministic queue/port adapter for the coordination boundary.
|
||||||
* It intentionally has no fleet/tmux dependency. A future deployment adapter
|
* 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 handoffs = new Map<string, StoredHandoff>();
|
||||||
private readonly now: () => Date;
|
private readonly now: () => Date;
|
||||||
private readonly handoffTtlMs: number;
|
private readonly handoffTtlMs: number;
|
||||||
private readonly maxHandoffs: number;
|
private readonly maxHandoffs: number;
|
||||||
|
|
||||||
constructor(options: InMemoryMosCoordinationPortOptions = {}) {
|
constructor(options: InMemoryInteractionCoordinationPortOptions = {}) {
|
||||||
this.now = options.now ?? (() => new Date());
|
this.now = options.now ?? (() => new Date());
|
||||||
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
|
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
|
||||||
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
|
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handoff(handoff: MosHandoff): Promise<MosHandoffReceipt> {
|
async handoff(handoff: Handoff): Promise<HandoffReceipt> {
|
||||||
this.pruneExpiredHandoffs();
|
this.pruneExpiredHandoffs();
|
||||||
const existing = this.handoffs.get(handoff.handoffId);
|
const existing = this.handoffs.get(handoff.handoffId);
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
@@ -88,14 +88,14 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Host-side progression seam; interaction clients never receive this capability. */
|
/** 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();
|
this.pruneExpiredHandoffs();
|
||||||
const stored = this.requireHandoff(handoffId);
|
const stored = this.requireHandoff(handoffId);
|
||||||
stored.status = status;
|
stored.status = status;
|
||||||
stored.activity.push(activity(status, summary, this.now));
|
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 {
|
recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void {
|
||||||
this.pruneExpiredHandoffs();
|
this.pruneExpiredHandoffs();
|
||||||
const stored = this.requireHandoff(handoffId);
|
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 {
|
return {
|
||||||
handoffId: handoff.handoffId,
|
handoffId: handoff.handoffId,
|
||||||
targetAgentId: handoff.targetAgentId,
|
targetAgentId: handoff.targetAgentId,
|
||||||
@@ -141,7 +141,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
|||||||
stored.handoff.scope.actorId !== scope.actorId ||
|
stored.handoff.scope.actorId !== scope.actorId ||
|
||||||
stored.handoff.scope.requesterAgentId !== scope.requesterAgentId
|
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;
|
return stored;
|
||||||
}
|
}
|
||||||
@@ -149,12 +149,12 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
|||||||
private requireHandoff(handoffId: string): StoredHandoff {
|
private requireHandoff(handoffId: string): StoredHandoff {
|
||||||
const stored = this.handoffs.get(handoffId);
|
const stored = this.handoffs.get(handoffId);
|
||||||
if (stored === undefined) {
|
if (stored === undefined) {
|
||||||
throw new InMemoryMosCoordinationError('not_found', 'Handoff was not found');
|
throw new InMemoryInteractionCoordinationError('not_found', 'Handoff was not found');
|
||||||
}
|
}
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertSameHandoff(existing: MosHandoff, incoming: MosHandoff): void {
|
private assertSameHandoff(existing: Handoff, incoming: Handoff): void {
|
||||||
if (
|
if (
|
||||||
existing.targetAgentId !== incoming.targetAgentId ||
|
existing.targetAgentId !== incoming.targetAgentId ||
|
||||||
existing.request.idempotencyKey !== incoming.request.idempotencyKey ||
|
existing.request.idempotencyKey !== incoming.request.idempotencyKey ||
|
||||||
@@ -166,7 +166,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
|||||||
existing.scope.correlationId !== incoming.scope.correlationId ||
|
existing.scope.correlationId !== incoming.scope.correlationId ||
|
||||||
existing.scope.requesterAgentId !== incoming.scope.requesterAgentId
|
existing.scope.requesterAgentId !== incoming.scope.requesterAgentId
|
||||||
) {
|
) {
|
||||||
throw new InMemoryMosCoordinationError(
|
throw new InMemoryInteractionCoordinationError(
|
||||||
'conflict',
|
'conflict',
|
||||||
'Handoff ID is already bound to different immutable input',
|
'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(
|
constructor(
|
||||||
readonly code: InMemoryMosCoordinationErrorCode,
|
readonly code: InMemoryInteractionCoordinationErrorCode,
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = InMemoryMosCoordinationError.name;
|
this.name = InMemoryInteractionCoordinationError.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activity(
|
function activity(
|
||||||
status: MosHandoffStatus,
|
status: HandoffStatus,
|
||||||
summary: string,
|
summary: string,
|
||||||
now: () => Date,
|
now: () => Date,
|
||||||
): MosCoordinationActivity {
|
): InteractionCoordinationActivity {
|
||||||
return { occurredAt: now().toISOString(), status, summary };
|
return { occurredAt: now().toISOString(), status, summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyActivity(entry: MosCoordinationActivity): MosCoordinationActivity {
|
function copyActivity(entry: InteractionCoordinationActivity): InteractionCoordinationActivity {
|
||||||
return { ...entry };
|
return { ...entry };
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotHandoff(handoff: MosHandoff): MosHandoff {
|
function snapshotHandoff(handoff: Handoff): Handoff {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
handoffId: handoff.handoffId,
|
handoffId: handoff.handoffId,
|
||||||
targetAgentId: handoff.targetAgentId,
|
targetAgentId: handoff.targetAgentId,
|
||||||
@@ -3,22 +3,25 @@ export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.j
|
|||||||
export { runTask, resumeTask } from './runner.js';
|
export { runTask, resumeTask } from './runner.js';
|
||||||
export { getMissionStatus, getTaskStatus } from './status.js';
|
export { getMissionStatus, getTaskStatus } from './status.js';
|
||||||
export {
|
export {
|
||||||
InMemoryMosCoordinationError,
|
InMemoryInteractionCoordinationError,
|
||||||
InMemoryMosCoordinationPort,
|
InMemoryInteractionCoordinationPort,
|
||||||
} from './in-memory-mos-coordination-port.js';
|
} from './in-memory-interaction-coordination-port.js';
|
||||||
export { MosCoordinationAuthorityError, MosCoordinationClient } from './mos-coordination.js';
|
export {
|
||||||
|
InteractionCoordinationAuthorityError,
|
||||||
|
InteractionCoordinationClient,
|
||||||
|
} from './interaction-coordination.js';
|
||||||
export type {
|
export type {
|
||||||
CoordinationObservation,
|
CoordinationObservation,
|
||||||
CoordinationResult,
|
CoordinationResult,
|
||||||
CoordinationScope,
|
CoordinationScope,
|
||||||
MosCoordinationActivity,
|
InteractionCoordinationActivity,
|
||||||
MosCoordinationIdentity,
|
InteractionCoordinationIdentity,
|
||||||
MosCoordinationPort,
|
InteractionCoordinationPort,
|
||||||
MosHandoff,
|
Handoff,
|
||||||
MosHandoffReceipt,
|
HandoffReceipt,
|
||||||
MosHandoffRequest,
|
HandoffRequest,
|
||||||
MosHandoffStatus,
|
HandoffStatus,
|
||||||
} from './mos-coordination.js';
|
} from './interaction-coordination.js';
|
||||||
export type {
|
export type {
|
||||||
CreateMissionOptions,
|
CreateMissionOptions,
|
||||||
Mission,
|
Mission,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type MosHandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
|
export type HandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
|
||||||
|
|
||||||
export interface CoordinationScope {
|
export interface CoordinationScope {
|
||||||
readonly actorId: string;
|
readonly actorId: string;
|
||||||
@@ -8,44 +8,44 @@ export interface CoordinationScope {
|
|||||||
readonly requesterAgentId: string;
|
readonly requesterAgentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationIdentity {
|
export interface InteractionCoordinationIdentity {
|
||||||
readonly interactionAgentId: string;
|
readonly interactionAgentId: string;
|
||||||
readonly orchestrationAgentId: string;
|
readonly orchestrationAgentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosHandoffRequest {
|
export interface HandoffRequest {
|
||||||
readonly idempotencyKey: string;
|
readonly idempotencyKey: string;
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
readonly context?: string;
|
readonly context?: string;
|
||||||
readonly missionId?: string;
|
readonly missionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosHandoff {
|
export interface Handoff {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly request: MosHandoffRequest;
|
readonly request: HandoffRequest;
|
||||||
readonly scope: CoordinationScope;
|
readonly scope: CoordinationScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosHandoffReceipt {
|
export interface HandoffReceipt {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly status: 'queued' | 'accepted';
|
readonly status: 'queued' | 'accepted';
|
||||||
readonly correlationId: string;
|
readonly correlationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationActivity {
|
export interface InteractionCoordinationActivity {
|
||||||
readonly occurredAt: string;
|
readonly occurredAt: string;
|
||||||
readonly status: MosHandoffStatus;
|
readonly status: HandoffStatus;
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoordinationObservation {
|
export interface CoordinationObservation {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly status: MosHandoffStatus;
|
readonly status: HandoffStatus;
|
||||||
readonly correlationId: string;
|
readonly correlationId: string;
|
||||||
readonly activity: readonly MosCoordinationActivity[];
|
readonly activity: readonly InteractionCoordinationActivity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoordinationResult {
|
export interface CoordinationResult {
|
||||||
@@ -61,25 +61,25 @@ export interface CoordinationResult {
|
|||||||
* its progress/result, but it cannot issue worker, review, merge, or other
|
* its progress/result, but it cannot issue worker, review, merge, or other
|
||||||
* general orchestration commands.
|
* general orchestration commands.
|
||||||
*/
|
*/
|
||||||
export interface MosCoordinationPort {
|
export interface InteractionCoordinationPort {
|
||||||
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
handoff(handoff: Handoff): Promise<HandoffReceipt>;
|
||||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MosCoordinationAuthorityErrorCode =
|
export type InteractionCoordinationAuthorityErrorCode =
|
||||||
| 'invalid_identity'
|
| 'invalid_identity'
|
||||||
| 'requester_forbidden'
|
| 'requester_forbidden'
|
||||||
| 'target_drift'
|
| 'target_drift'
|
||||||
| 'correlation_drift';
|
| 'correlation_drift';
|
||||||
|
|
||||||
export class MosCoordinationAuthorityError extends Error {
|
export class InteractionCoordinationAuthorityError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code: MosCoordinationAuthorityErrorCode,
|
readonly code: InteractionCoordinationAuthorityErrorCode,
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
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
|
* Enforces the interaction-to-orchestration authority boundary before a
|
||||||
* transport is reached. Identity names remain configuration data.
|
* transport is reached. Identity names remain configuration data.
|
||||||
*/
|
*/
|
||||||
export class MosCoordinationClient {
|
export class InteractionCoordinationClient {
|
||||||
private readonly identity: MosCoordinationIdentity;
|
private readonly identity: InteractionCoordinationIdentity;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
identity: MosCoordinationIdentity,
|
identity: InteractionCoordinationIdentity,
|
||||||
private readonly port: MosCoordinationPort,
|
private readonly port: InteractionCoordinationPort,
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
) {
|
) {
|
||||||
this.identity = normalizeIdentity(identity);
|
this.identity = normalizeIdentity(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handoff(request: MosHandoffRequest, scope: CoordinationScope): Promise<MosHandoffReceipt> {
|
async handoff(request: HandoffRequest, scope: CoordinationScope): Promise<HandoffReceipt> {
|
||||||
this.assertRequester(scope);
|
this.assertRequester(scope);
|
||||||
const handoff: MosHandoff = {
|
const handoff: Handoff = {
|
||||||
handoffId: this.handoffIdFactory(),
|
handoffId: this.handoffIdFactory(),
|
||||||
targetAgentId: this.identity.orchestrationAgentId,
|
targetAgentId: this.identity.orchestrationAgentId,
|
||||||
request: snapshotRequest(request),
|
request: snapshotRequest(request),
|
||||||
@@ -111,15 +111,15 @@ export class MosCoordinationClient {
|
|||||||
receipt.handoffId !== handoff.handoffId ||
|
receipt.handoffId !== handoff.handoffId ||
|
||||||
receipt.targetAgentId !== handoff.targetAgentId
|
receipt.targetAgentId !== handoff.targetAgentId
|
||||||
) {
|
) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'target_drift',
|
'target_drift',
|
||||||
'Mos coordination transport returned a mismatched handoff target',
|
'Interaction coordination transport returned a mismatched handoff target',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (receipt.correlationId !== handoff.scope.correlationId) {
|
if (receipt.correlationId !== handoff.scope.correlationId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'correlation_drift',
|
'correlation_drift',
|
||||||
'Mos coordination transport returned a mismatched correlation ID',
|
'Interaction coordination transport returned a mismatched correlation ID',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return receipt;
|
return receipt;
|
||||||
@@ -137,7 +137,7 @@ export class MosCoordinationClient {
|
|||||||
|
|
||||||
private assertRequester(scope: CoordinationScope): void {
|
private assertRequester(scope: CoordinationScope): void {
|
||||||
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
|
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'requester_forbidden',
|
'requester_forbidden',
|
||||||
'Requester is not the configured interaction agent',
|
'Requester is not the configured interaction agent',
|
||||||
);
|
);
|
||||||
@@ -149,15 +149,15 @@ export class MosCoordinationClient {
|
|||||||
scope: CoordinationScope,
|
scope: CoordinationScope,
|
||||||
): CoordinationObservation {
|
): CoordinationObservation {
|
||||||
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
|
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'target_drift',
|
'target_drift',
|
||||||
'Mos coordination transport returned an unexpected observation target',
|
'Interaction coordination transport returned an unexpected observation target',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (observation.correlationId !== scope.correlationId) {
|
if (observation.correlationId !== scope.correlationId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'correlation_drift',
|
'correlation_drift',
|
||||||
'Mos coordination transport returned a mismatched observation correlation ID',
|
'Interaction coordination transport returned a mismatched observation correlation ID',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return observation;
|
return observation;
|
||||||
@@ -165,32 +165,34 @@ export class MosCoordinationClient {
|
|||||||
|
|
||||||
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
|
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
|
||||||
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
|
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'target_drift',
|
'target_drift',
|
||||||
'Mos coordination transport returned an unexpected result target',
|
'Interaction coordination transport returned an unexpected result target',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (result.correlationId !== scope.correlationId) {
|
if (result.correlationId !== scope.correlationId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'correlation_drift',
|
'correlation_drift',
|
||||||
'Mos coordination transport returned a mismatched result correlation ID',
|
'Interaction coordination transport returned a mismatched result correlation ID',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationIdentity {
|
function normalizeIdentity(
|
||||||
|
identity: InteractionCoordinationIdentity,
|
||||||
|
): InteractionCoordinationIdentity {
|
||||||
const interactionAgentId = identity.interactionAgentId.trim();
|
const interactionAgentId = identity.interactionAgentId.trim();
|
||||||
const orchestrationAgentId = identity.orchestrationAgentId.trim();
|
const orchestrationAgentId = identity.orchestrationAgentId.trim();
|
||||||
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
|
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'invalid_identity',
|
'invalid_identity',
|
||||||
'Interaction and orchestration identities are required',
|
'Interaction and orchestration identities are required',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (interactionAgentId === orchestrationAgentId) {
|
if (interactionAgentId === orchestrationAgentId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'invalid_identity',
|
'invalid_identity',
|
||||||
'Interaction and orchestration identities must differ',
|
'Interaction and orchestration identities must differ',
|
||||||
);
|
);
|
||||||
@@ -198,7 +200,7 @@ function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationId
|
|||||||
return Object.freeze({ interactionAgentId, orchestrationAgentId });
|
return Object.freeze({ interactionAgentId, orchestrationAgentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotRequest(request: MosHandoffRequest): MosHandoffRequest {
|
function snapshotRequest(request: HandoffRequest): HandoffRequest {
|
||||||
return Object.freeze({ ...request });
|
return Object.freeze({ ...request });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
The **operator-interaction** role is the authorized human interaction plane for
|
The **operator-interaction** role is the authorized human interaction plane for
|
||||||
Mosaic. It presents runtime and fleet state, mediates approved actions, and
|
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
|
## 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 exposes only the configured, observable tool policy.
|
||||||
- It does not receive or surface credentials in its effective policy.
|
- It does not receive or surface credentials in its effective policy.
|
||||||
|
|||||||
@@ -75,6 +75,14 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
"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": {
|
"runtime": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -189,6 +189,36 @@ describe('fleet roster parsing', () => {
|
|||||||
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
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)', () => {
|
it('socketArgs: named socket → -L <name>; empty → no -L (default socket)', () => {
|
||||||
expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']);
|
expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']);
|
||||||
expect(socketArgs('')).toEqual([]);
|
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 () => {
|
it('produces --json records including tenant_id and host for each agent', async () => {
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ interface RawFleetRoster {
|
|||||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||||
agents?: Array<{
|
agents?: Array<{
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
|
alias?: unknown;
|
||||||
|
provider?: unknown;
|
||||||
runtime?: unknown;
|
runtime?: unknown;
|
||||||
class?: unknown;
|
class?: unknown;
|
||||||
working_directory?: unknown;
|
working_directory?: unknown;
|
||||||
@@ -102,6 +104,8 @@ interface RawFleetRoster {
|
|||||||
|
|
||||||
export interface FleetAgent {
|
export interface FleetAgent {
|
||||||
name: string;
|
name: string;
|
||||||
|
alias?: string;
|
||||||
|
provider?: string;
|
||||||
runtime: string;
|
runtime: string;
|
||||||
className: string;
|
className: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
@@ -1074,6 +1078,7 @@ export interface HeartbeatInfo {
|
|||||||
|
|
||||||
export interface AgentPsRow {
|
export interface AgentPsRow {
|
||||||
name: string;
|
name: string;
|
||||||
|
alias?: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
host: string;
|
host: string;
|
||||||
runtime: string;
|
runtime: string;
|
||||||
@@ -1755,6 +1760,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
|
alias: agent.alias,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
host,
|
host,
|
||||||
runtime: agent.runtime,
|
runtime: agent.runtime,
|
||||||
@@ -1888,7 +1894,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
[
|
[
|
||||||
row.name.padEnd(18),
|
(row.alias ?? row.name).padEnd(18),
|
||||||
row.tenant_id.padEnd(12),
|
row.tenant_id.padEnd(12),
|
||||||
row.host.padEnd(12),
|
row.host.padEnd(12),
|
||||||
row.runtime.padEnd(10),
|
row.runtime.padEnd(10),
|
||||||
@@ -2494,6 +2500,8 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
|||||||
assertObject(raw, 'Fleet roster agent');
|
assertObject(raw, 'Fleet roster agent');
|
||||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||||
'name',
|
'name',
|
||||||
|
'alias',
|
||||||
|
'provider',
|
||||||
'runtime',
|
'runtime',
|
||||||
'class',
|
'class',
|
||||||
'working_directory',
|
'working_directory',
|
||||||
@@ -2525,6 +2533,8 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
||||||
|
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
||||||
runtime,
|
runtime,
|
||||||
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||||
workingDirectory: optionalString(
|
workingDirectory: optionalString(
|
||||||
@@ -2827,6 +2837,12 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
|||||||
runtime: agent.runtime,
|
runtime: agent.runtime,
|
||||||
class: agent.className,
|
class: agent.className,
|
||||||
};
|
};
|
||||||
|
if (agent.alias !== undefined) {
|
||||||
|
raw['alias'] = agent.alias;
|
||||||
|
}
|
||||||
|
if (agent.provider !== undefined) {
|
||||||
|
raw['provider'] = agent.provider;
|
||||||
|
}
|
||||||
if (agent.workingDirectory !== undefined) {
|
if (agent.workingDirectory !== undefined) {
|
||||||
raw['working_directory'] = agent.workingDirectory;
|
raw['working_directory'] = agent.workingDirectory;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ function readFleetToolPolicyBlock(policy: string | undefined): string {
|
|||||||
'',
|
'',
|
||||||
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
||||||
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
|
'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');
|
].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