Compare commits
2 Commits
docs/issue
...
feat/mos-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff8ce4f79 | ||
|
|
9d10bdcf87 |
@@ -21,6 +21,12 @@ import { LogModule } from '../log/log.module.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||
import {
|
||||
CONNECTOR_LEASE_POLICY,
|
||||
ConnectorLeaseService,
|
||||
DenyConnectorLeasePolicy,
|
||||
} from './connector-lease.service.js';
|
||||
import {
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
RUNTIME_APPROVAL_VERIFIER,
|
||||
@@ -46,6 +52,13 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
||||
SkillLoaderService,
|
||||
DurableSessionRepository,
|
||||
DurableSessionService,
|
||||
ConnectorLeaseRepository,
|
||||
DenyConnectorLeasePolicy,
|
||||
{
|
||||
provide: CONNECTOR_LEASE_POLICY,
|
||||
useExisting: DenyConnectorLeasePolicy,
|
||||
},
|
||||
ConnectorLeaseService,
|
||||
{
|
||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
useFactory: createGatewayRuntimeProviderRegistry,
|
||||
@@ -78,6 +91,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
||||
SkillLoaderService,
|
||||
DurableSessionService,
|
||||
RuntimeProviderService,
|
||||
ConnectorLeaseService,
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
],
|
||||
})
|
||||
|
||||
232
apps/gateway/src/agent/connector-lease.integration.test.ts
Normal file
232
apps/gateway/src/agent/connector-lease.integration.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
connectorLeaseAuditLog,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
runPgliteMigrations,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import type { ConnectorExecutionContext, FencedConnectorAdapter } from '@mosaicstack/types';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||
import {
|
||||
CONNECTOR_LEASE_POLICY,
|
||||
ConnectorLeaseService,
|
||||
type ConnectorLeasePolicy,
|
||||
} from './connector-lease.service.js';
|
||||
|
||||
const authorize = vi.fn().mockResolvedValue(true);
|
||||
const policy: ConnectorLeasePolicy = { authorize };
|
||||
const context = {
|
||||
actorScope: { userId: 'operator-a', tenantId: 'tenant-a' },
|
||||
correlationId: 'correlation-acquire',
|
||||
};
|
||||
|
||||
describe('gateway connector lease fencing integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let service: ConnectorLeaseService;
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-14T17:00:00.000Z'));
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-connector-lease-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
ConnectorLeaseRepository,
|
||||
ConnectorLeaseService,
|
||||
{ provide: DB, useValue: handle.db },
|
||||
{ provide: CONNECTOR_LEASE_POLICY, useValue: policy },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(ConnectorLeaseService);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
vi.useRealTimers();
|
||||
await moduleRef.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('derives tenant authority at the gateway and validates a grant before side effects', async (): Promise<void> => {
|
||||
const lease = await service.acquire(
|
||||
{
|
||||
logicalAgentId: 'Mos',
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'pi-worker-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
},
|
||||
context,
|
||||
);
|
||||
const grant = await service.issueGrant(
|
||||
{ lease, scopes: ['runtime.send'], ttlMs: 30_000 },
|
||||
{ ...context, correlationId: 'correlation-grant' },
|
||||
);
|
||||
const execute = vi.fn(async (_message: string, leaseContext: ConnectorExecutionContext) => {
|
||||
return leaseContext.leaseEpoch;
|
||||
});
|
||||
const adapter: FencedConnectorAdapter<string, string> = { execute };
|
||||
|
||||
await expect(service.executeGrant(grant, 'runtime.send', 'hello', adapter)).resolves.toBe('1');
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'grant.issue',
|
||||
requestedScopes: ['runtime.send'],
|
||||
requestedTtlMs: 30_000,
|
||||
}),
|
||||
);
|
||||
expect(execute.mock.calls[0]?.[1]).toMatchObject({
|
||||
identity: { tenantId: 'tenant-a', logicalAgentId: 'mos' },
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'pi-worker-a',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes lease-derived policy subjects before authorization', async (): Promise<void> => {
|
||||
const lease = await service.acquire(
|
||||
{
|
||||
logicalAgentId: 'mos',
|
||||
bindingId: 'operator-chat-policy',
|
||||
connectorId: 'pi-worker-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
},
|
||||
{ ...context, correlationId: 'correlation-policy-setup' },
|
||||
);
|
||||
const aliasedLease = {
|
||||
...lease,
|
||||
identity: { ...lease.identity, logicalAgentId: ' MOS ' },
|
||||
bindingId: ' Operator-Chat-Policy ',
|
||||
connectorId: ' PI-Worker-A ',
|
||||
scopes: [' Runtime.Send '],
|
||||
leaseEpoch: `00${lease.leaseEpoch}`,
|
||||
};
|
||||
|
||||
await service.heartbeat(aliasedLease, 30_000, {
|
||||
...context,
|
||||
correlationId: 'correlation-policy-heartbeat',
|
||||
});
|
||||
expect(authorize).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'lease.heartbeat',
|
||||
logicalAgentId: 'mos',
|
||||
bindingId: 'operator-chat-policy',
|
||||
connectorId: 'pi-worker-a',
|
||||
requestedScopes: ['runtime.send'],
|
||||
}),
|
||||
);
|
||||
|
||||
await service.issueGrant(
|
||||
{ lease: aliasedLease, scopes: [' Runtime.Send '], ttlMs: 1_000 },
|
||||
{ ...context, correlationId: 'correlation-policy-grant' },
|
||||
);
|
||||
expect(authorize).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'grant.issue',
|
||||
logicalAgentId: 'mos',
|
||||
bindingId: 'operator-chat-policy',
|
||||
connectorId: 'pi-worker-a',
|
||||
requestedScopes: ['runtime.send'],
|
||||
}),
|
||||
);
|
||||
|
||||
await service.release(aliasedLease, {
|
||||
...context,
|
||||
correlationId: 'correlation-policy-release',
|
||||
});
|
||||
expect(authorize).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'lease.release',
|
||||
logicalAgentId: 'mos',
|
||||
bindingId: 'operator-chat-policy',
|
||||
connectorId: 'pi-worker-a',
|
||||
requestedScopes: ['runtime.send'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('denies stale, forged, expired, cross-tenant, and cross-binding grants before effects', async (): Promise<void> => {
|
||||
const bindingId = 'operator-chat-denials';
|
||||
const current = await service.acquire(
|
||||
{
|
||||
logicalAgentId: 'mos',
|
||||
bindingId,
|
||||
connectorId: 'pi-worker-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
},
|
||||
{ ...context, correlationId: 'correlation-denial-setup' },
|
||||
);
|
||||
const stale = await service.issueGrant(
|
||||
{ lease: current, scopes: ['runtime.send'], ttlMs: 30_000 },
|
||||
{ ...context, correlationId: 'correlation-stale' },
|
||||
);
|
||||
await service.takeover(
|
||||
{
|
||||
logicalAgentId: 'mos',
|
||||
bindingId,
|
||||
connectorId: 'pi-worker-b',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
expectedEpoch: current.leaseEpoch,
|
||||
},
|
||||
{ ...context, correlationId: 'correlation-takeover' },
|
||||
);
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(service.executeGrant(stale, 'runtime.send', undefined, adapter)).rejects.toThrow();
|
||||
|
||||
const active = await service.current('mos', bindingId, context);
|
||||
if (!active) throw new Error('active lease fixture is unavailable');
|
||||
const grant = await service.issueGrant(
|
||||
{ lease: active, scopes: ['runtime.send'], ttlMs: 1_000 },
|
||||
{ ...context, correlationId: 'correlation-active' },
|
||||
);
|
||||
await expect(
|
||||
service.executeGrant({ ...grant }, 'runtime.send', undefined, adapter),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
service.executeGrant(
|
||||
{ ...grant, bindingId: 'other-binding' },
|
||||
'runtime.send',
|
||||
undefined,
|
||||
adapter,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
service.issueGrant(
|
||||
{ lease: active, scopes: ['runtime.send'], ttlMs: 30_000 },
|
||||
{
|
||||
actorScope: { userId: 'operator-b', tenantId: 'tenant-b' },
|
||||
correlationId: 'correlation-cross-tenant',
|
||||
},
|
||||
),
|
||||
).rejects.toThrow();
|
||||
const crossTenantAudit = await handle.db
|
||||
.select()
|
||||
.from(connectorLeaseAuditLog)
|
||||
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-cross-tenant'));
|
||||
expect(crossTenantAudit).toHaveLength(1);
|
||||
expect(crossTenantAudit[0]).toMatchObject({
|
||||
tenantId: 'tenant-b',
|
||||
logicalAgentId: 'untrusted',
|
||||
bindingId: 'untrusted',
|
||||
connectorId: 'untrusted',
|
||||
reason: 'policy_denied',
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-07-14T17:00:02.000Z'));
|
||||
await expect(service.executeGrant(grant, 'runtime.send', undefined, adapter)).rejects.toThrow();
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
connectorLeaseAuditLog,
|
||||
createDb,
|
||||
eq,
|
||||
logicalAgentConnectorLeases,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { ConnectorLeaseCoordinator } from '@mosaicstack/agent';
|
||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||
|
||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||
const tenantId = `lease-test-${randomUUID()}`;
|
||||
const identity = { tenantId, logicalAgentId: 'mos' } as const;
|
||||
|
||||
describe.skipIf(!hasPostgres)('ConnectorLeaseRepository real PostgreSQL integration', (): void => {
|
||||
let handle: DbHandle;
|
||||
|
||||
beforeAll((): void => {
|
||||
handle = createDb(process.env['DATABASE_URL']);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
if (!handle) return;
|
||||
await handle.db
|
||||
.delete(connectorLeaseAuditLog)
|
||||
.where(eq(connectorLeaseAuditLog.tenantId, tenantId));
|
||||
await handle.db
|
||||
.delete(logicalAgentConnectorLeases)
|
||||
.where(eq(logicalAgentConnectorLeases.tenantId, tenantId));
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it('preserves the exclusive CAS fence across a real pool close/reopen', async (): Promise<void> => {
|
||||
const command = {
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
} as const;
|
||||
const firstCoordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
|
||||
const contenders = await Promise.allSettled([
|
||||
firstCoordinator.acquire({
|
||||
...command,
|
||||
connectorId: 'connector-a',
|
||||
correlationId: 'postgres-acquire-a',
|
||||
}),
|
||||
firstCoordinator.acquire({
|
||||
...command,
|
||||
connectorId: 'connector-b',
|
||||
correlationId: 'postgres-acquire-b',
|
||||
}),
|
||||
]);
|
||||
const acquired = contenders.find((result) => result.status === 'fulfilled');
|
||||
if (!acquired || acquired.status !== 'fulfilled') throw new Error('no lease contender won');
|
||||
expect(contenders.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
|
||||
await handle.close();
|
||||
handle = createDb(process.env['DATABASE_URL']);
|
||||
const reopened = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
|
||||
const persisted = await reopened.current({ identity, bindingId: 'operator-chat' });
|
||||
expect(persisted).toMatchObject({
|
||||
leaseId: acquired.value.leaseId,
|
||||
leaseEpoch: '1',
|
||||
});
|
||||
|
||||
const takeover = await reopened.takeover({
|
||||
...command,
|
||||
connectorId: 'connector-c',
|
||||
correlationId: 'postgres-takeover',
|
||||
expectedEpoch: acquired.value.leaseEpoch,
|
||||
});
|
||||
expect(takeover).toMatchObject({ connectorId: 'connector-c', leaseEpoch: '2' });
|
||||
});
|
||||
});
|
||||
149
apps/gateway/src/agent/connector-lease.repository.test.ts
Normal file
149
apps/gateway/src/agent/connector-lease.repository.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
connectorLeaseAuditLog,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
runPgliteMigrations,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { ConnectorLeaseCoordinator, ConnectorLeaseError } from '@mosaicstack/agent';
|
||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||
|
||||
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
|
||||
|
||||
function acquireCommand(connectorId: string, correlationId: string) {
|
||||
return {
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
connectorId,
|
||||
scopes: ['runtime.send', 'tool.execute'],
|
||||
ttlMs: 60_000,
|
||||
correlationId,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ConnectorLeaseRepository PostgreSQL semantics', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let now: Date;
|
||||
let coordinator: ConnectorLeaseCoordinator;
|
||||
|
||||
beforeEach(async (): Promise<void> => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-connector-lease-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
now = new Date('2026-07-14T17:00:00.000Z');
|
||||
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
|
||||
now: (): Date => now,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('allows only one concurrent contender to acquire a binding', async (): Promise<void> => {
|
||||
const outcomes = await Promise.allSettled([
|
||||
coordinator.acquire(acquireCommand('connector-a', 'correlation-a')),
|
||||
coordinator.acquire(acquireCommand('connector-b', 'correlation-b')),
|
||||
]);
|
||||
|
||||
expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
const rejected = outcomes.find((result) => result.status === 'rejected');
|
||||
expect(rejected).toMatchObject({
|
||||
reason: { code: 'lease_held' } satisfies Partial<ConnectorLeaseError>,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise<void> => {
|
||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||
const results = await Promise.allSettled([
|
||||
coordinator.takeover({
|
||||
...acquireCommand('connector-b', 'correlation-b'),
|
||||
expectedEpoch: acquired.leaseEpoch,
|
||||
}),
|
||||
coordinator.takeover({
|
||||
...acquireCommand('connector-c', 'correlation-c'),
|
||||
expectedEpoch: acquired.leaseEpoch,
|
||||
}),
|
||||
]);
|
||||
const winner = results.find((result) => result.status === 'fulfilled');
|
||||
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
expect(winner?.status === 'fulfilled' ? winner.value.leaseEpoch : null).toBe('2');
|
||||
expect(results.find((result) => result.status === 'rejected')).toMatchObject({
|
||||
reason: { code: 'cas_mismatch' } satisfies Partial<ConnectorLeaseError>,
|
||||
});
|
||||
});
|
||||
|
||||
it('heartbeats and releases only the current connector epoch', async (): Promise<void> => {
|
||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||
now = new Date('2026-07-14T17:00:30.000Z');
|
||||
const renewed = await coordinator.heartbeat({
|
||||
lease: acquired,
|
||||
ttlMs: 120_000,
|
||||
correlationId: 'correlation-renew',
|
||||
});
|
||||
expect(renewed.expiresAt).toBe('2026-07-14T17:02:30.000Z');
|
||||
|
||||
await coordinator.release({ lease: renewed, correlationId: 'correlation-release' });
|
||||
await expect(
|
||||
coordinator.heartbeat({
|
||||
lease: renewed,
|
||||
ttlMs: 120_000,
|
||||
correlationId: 'correlation-stale',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'lease_released' } satisfies Partial<ConnectorLeaseError>);
|
||||
});
|
||||
|
||||
it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise<void> => {
|
||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||
await handle.close();
|
||||
|
||||
now = new Date('2026-07-14T17:02:00.000Z');
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
|
||||
now: (): Date => now,
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.acquire(acquireCommand('connector-b', 'correlation-plain-acquire')),
|
||||
).rejects.toMatchObject({ code: 'takeover_required' } satisfies Partial<ConnectorLeaseError>);
|
||||
const recovered = await coordinator.takeover({
|
||||
...acquireCommand('connector-b', 'correlation-takeover'),
|
||||
expectedEpoch: acquired.leaseEpoch,
|
||||
});
|
||||
expect(recovered).toMatchObject({ connectorId: 'connector-b', leaseEpoch: '2' });
|
||||
});
|
||||
|
||||
it('writes credential-safe lifecycle and rejection audit records', async (): Promise<void> => {
|
||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||
await coordinator.heartbeat({
|
||||
lease: acquired,
|
||||
ttlMs: 60_000,
|
||||
correlationId: 'correlation-renew',
|
||||
});
|
||||
await expect(
|
||||
coordinator.acquire(acquireCommand('connector-b', 'correlation-reject')),
|
||||
).rejects.toBeInstanceOf(ConnectorLeaseError);
|
||||
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(connectorLeaseAuditLog)
|
||||
.where(eq(connectorLeaseAuditLog.tenantId, identity.tenantId));
|
||||
expect(rows.map((row) => row.event)).toEqual(
|
||||
expect.arrayContaining(['acquire', 'renew', 'reject']),
|
||||
);
|
||||
const serialized = JSON.stringify(rows, (_key: string, value: unknown): unknown =>
|
||||
typeof value === 'bigint' ? value.toString(10) : value,
|
||||
);
|
||||
expect(serialized).not.toContain('tool.execute');
|
||||
expect(serialized).not.toContain('runtime.send');
|
||||
expect(serialized).not.toMatch(/token|secret|credential/i);
|
||||
});
|
||||
});
|
||||
354
apps/gateway/src/agent/connector-lease.repository.ts
Normal file
354
apps/gateway/src/agent/connector-lease.repository.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
connectorLeaseAuditLog,
|
||||
eq,
|
||||
gt,
|
||||
isNull,
|
||||
logicalAgentConnectorLeases,
|
||||
sql,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { ConnectorLeaseError } from '@mosaicstack/agent';
|
||||
import type {
|
||||
ConnectorLease,
|
||||
ConnectorLeaseAcquireMutation,
|
||||
ConnectorLeaseAuditEvent,
|
||||
ConnectorLeaseHeartbeatMutation,
|
||||
ConnectorLeaseRejectReason,
|
||||
ConnectorLeaseReleaseMutation,
|
||||
ConnectorLeaseStore,
|
||||
ConnectorLeaseTakeoverMutation,
|
||||
LogicalAgentBinding,
|
||||
} from '@mosaicstack/types';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
interface SuccessfulMutation {
|
||||
readonly ok: true;
|
||||
readonly lease: ConnectorLease;
|
||||
}
|
||||
|
||||
interface FailedMutation {
|
||||
readonly ok: false;
|
||||
readonly reason: ConnectorLeaseRejectReason;
|
||||
}
|
||||
|
||||
type MutationResult = SuccessfulMutation | FailedMutation;
|
||||
|
||||
@Injectable()
|
||||
export class ConnectorLeaseRepository implements ConnectorLeaseStore {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease> {
|
||||
const result: MutationResult = await this.db.transaction(
|
||||
async (tx): Promise<MutationResult> => {
|
||||
const inserted = await tx
|
||||
.insert(logicalAgentConnectorLeases)
|
||||
.values({
|
||||
leaseId: input.leaseId,
|
||||
tenantId: input.identity.tenantId,
|
||||
logicalAgentId: input.identity.logicalAgentId,
|
||||
bindingId: input.bindingId,
|
||||
connectorId: input.connectorId,
|
||||
scopes: [...input.scopes],
|
||||
leaseEpoch: 1n,
|
||||
acquiredAt: new Date(input.now),
|
||||
heartbeatAt: new Date(input.now),
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
updatedAt: new Date(input.now),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
if (row) {
|
||||
const lease = toLease(row);
|
||||
await insertAudit(tx, lifecycleAudit(input, lease, 'acquire'));
|
||||
return { ok: true, lease };
|
||||
}
|
||||
|
||||
const current = await findRow(tx, input);
|
||||
if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||
}
|
||||
const reason: ConnectorLeaseRejectReason =
|
||||
current && (current.releasedAt || current.expiresAt <= new Date(input.now))
|
||||
? 'takeover_required'
|
||||
: 'lease_held';
|
||||
await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason));
|
||||
return { ok: false, reason };
|
||||
},
|
||||
);
|
||||
return unwrap(result);
|
||||
}
|
||||
|
||||
async takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease> {
|
||||
const result: MutationResult = await this.db.transaction(
|
||||
async (tx): Promise<MutationResult> => {
|
||||
const current = await findRow(tx, input);
|
||||
if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) {
|
||||
await insertAudit(
|
||||
tx,
|
||||
rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'),
|
||||
);
|
||||
return { ok: false, reason: 'cas_mismatch' };
|
||||
}
|
||||
if (current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||
}
|
||||
const updated = await tx
|
||||
.update(logicalAgentConnectorLeases)
|
||||
.set({
|
||||
leaseId: input.leaseId,
|
||||
connectorId: input.connectorId,
|
||||
scopes: [...input.scopes],
|
||||
leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`,
|
||||
acquiredAt: new Date(input.now),
|
||||
heartbeatAt: new Date(input.now),
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
releasedAt: null,
|
||||
updatedAt: new Date(input.now),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
bindingPredicate(input),
|
||||
eq(logicalAgentConnectorLeases.leaseId, current.leaseId),
|
||||
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch'));
|
||||
return { ok: false, reason: 'cas_mismatch' };
|
||||
}
|
||||
const lease = toLease(row);
|
||||
await insertAudit(tx, lifecycleAudit(input, lease, 'takeover'));
|
||||
return { ok: true, lease };
|
||||
},
|
||||
);
|
||||
return unwrap(result);
|
||||
}
|
||||
|
||||
async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease> {
|
||||
const result: MutationResult = await this.db.transaction(
|
||||
async (tx): Promise<MutationResult> => {
|
||||
const updated = await tx
|
||||
.update(logicalAgentConnectorLeases)
|
||||
.set({
|
||||
heartbeatAt: new Date(input.now),
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
updatedAt: new Date(input.now),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
bindingPredicate(input.lease),
|
||||
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
||||
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
||||
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
||||
isNull(logicalAgentConnectorLeases.releasedAt),
|
||||
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (row) {
|
||||
const lease = toLease(row);
|
||||
await insertAudit(tx, lifecycleAudit(input, lease, 'renew'));
|
||||
return { ok: true, lease };
|
||||
}
|
||||
const current = await findRow(tx, input.lease);
|
||||
const reason = classifyAuthorityFailure(
|
||||
current ? toLease(current) : null,
|
||||
input.lease,
|
||||
input.now,
|
||||
);
|
||||
if (reason === 'lease_expired' && current) {
|
||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||
}
|
||||
await insertAudit(
|
||||
tx,
|
||||
rejectionAudit(
|
||||
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
||||
current ? toLease(current) : null,
|
||||
reason,
|
||||
),
|
||||
);
|
||||
return { ok: false, reason };
|
||||
},
|
||||
);
|
||||
return unwrap(result);
|
||||
}
|
||||
|
||||
async release(input: ConnectorLeaseReleaseMutation): Promise<void> {
|
||||
const result: MutationResult = await this.db.transaction(
|
||||
async (tx): Promise<MutationResult> => {
|
||||
const updated = await tx
|
||||
.update(logicalAgentConnectorLeases)
|
||||
.set({
|
||||
releasedAt: new Date(input.now),
|
||||
expiresAt: new Date(input.now),
|
||||
updatedAt: new Date(input.now),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
bindingPredicate(input.lease),
|
||||
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
||||
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
||||
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
||||
isNull(logicalAgentConnectorLeases.releasedAt),
|
||||
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (row) {
|
||||
const lease = toLease(row);
|
||||
await insertAudit(tx, lifecycleAudit(input, lease, 'release'));
|
||||
return { ok: true, lease };
|
||||
}
|
||||
const current = await findRow(tx, input.lease);
|
||||
const reason = classifyAuthorityFailure(
|
||||
current ? toLease(current) : null,
|
||||
input.lease,
|
||||
input.now,
|
||||
);
|
||||
if (reason === 'lease_expired' && current) {
|
||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||
}
|
||||
await insertAudit(
|
||||
tx,
|
||||
rejectionAudit(
|
||||
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
||||
current ? toLease(current) : null,
|
||||
reason,
|
||||
),
|
||||
);
|
||||
return { ok: false, reason };
|
||||
},
|
||||
);
|
||||
unwrap(result);
|
||||
}
|
||||
|
||||
async findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
|
||||
const row = await findRow(this.db, binding);
|
||||
return row ? toLease(row) : null;
|
||||
}
|
||||
|
||||
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||
await insertAudit(this.db, event);
|
||||
}
|
||||
}
|
||||
|
||||
function unwrap(result: MutationResult): ConnectorLease {
|
||||
if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason));
|
||||
return result.lease;
|
||||
}
|
||||
|
||||
function safeErrorMessage(reason: ConnectorLeaseRejectReason): string {
|
||||
return `Connector lease mutation denied: ${reason}`;
|
||||
}
|
||||
|
||||
function bindingPredicate(binding: LogicalAgentBinding) {
|
||||
return and(
|
||||
eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId),
|
||||
eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId),
|
||||
eq(logicalAgentConnectorLeases.bindingId, binding.bindingId),
|
||||
);
|
||||
}
|
||||
|
||||
async function findRow(
|
||||
db: Pick<Db, 'select'>,
|
||||
binding: LogicalAgentBinding,
|
||||
): Promise<typeof logicalAgentConnectorLeases.$inferSelect | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(logicalAgentConnectorLeases)
|
||||
.where(bindingPredicate(binding))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease {
|
||||
return Object.freeze({
|
||||
identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }),
|
||||
bindingId: row.bindingId,
|
||||
leaseId: row.leaseId,
|
||||
connectorId: row.connectorId,
|
||||
scopes: Object.freeze([...row.scopes]),
|
||||
leaseEpoch: row.leaseEpoch.toString(10),
|
||||
acquiredAt: row.acquiredAt.toISOString(),
|
||||
heartbeatAt: row.heartbeatAt.toISOString(),
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function classifyAuthorityFailure(
|
||||
current: ConnectorLease | null,
|
||||
claimed: ConnectorLease,
|
||||
now: string,
|
||||
): ConnectorLeaseRejectReason {
|
||||
if (!current) return 'lease_missing';
|
||||
if (current.releasedAt) return 'lease_released';
|
||||
if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired';
|
||||
if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch';
|
||||
return 'connector_mismatch';
|
||||
}
|
||||
|
||||
function lifecycleAudit(
|
||||
input: { readonly correlationId: string; readonly now: string },
|
||||
lease: ConnectorLease,
|
||||
event: Exclude<ConnectorLeaseAuditEvent['event'], 'reject'>,
|
||||
): ConnectorLeaseAuditEvent {
|
||||
return {
|
||||
identity: lease.identity,
|
||||
bindingId: lease.bindingId,
|
||||
connectorId: lease.connectorId,
|
||||
leaseId: lease.leaseId,
|
||||
leaseEpoch: lease.leaseEpoch,
|
||||
event,
|
||||
outcome: 'succeeded',
|
||||
correlationId: input.correlationId,
|
||||
occurredAt: input.now,
|
||||
};
|
||||
}
|
||||
|
||||
function rejectionAudit(
|
||||
input: {
|
||||
readonly identity: ConnectorLease['identity'];
|
||||
readonly bindingId: string;
|
||||
readonly connectorId: string;
|
||||
readonly correlationId: string;
|
||||
readonly now: string;
|
||||
},
|
||||
current: ConnectorLease | null,
|
||||
reason: ConnectorLeaseRejectReason,
|
||||
): ConnectorLeaseAuditEvent {
|
||||
return {
|
||||
identity: input.identity,
|
||||
bindingId: input.bindingId,
|
||||
connectorId: input.connectorId,
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
correlationId: input.correlationId,
|
||||
occurredAt: input.now,
|
||||
...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}),
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
async function insertAudit(db: Pick<Db, 'insert'>, event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||
await db.insert(connectorLeaseAuditLog).values({
|
||||
tenantId: event.identity.tenantId,
|
||||
logicalAgentId: event.identity.logicalAgentId,
|
||||
bindingId: event.bindingId,
|
||||
connectorId: event.connectorId,
|
||||
...(event.leaseId ? { leaseId: event.leaseId } : {}),
|
||||
...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}),
|
||||
event: event.event,
|
||||
outcome: event.outcome,
|
||||
...(event.reason ? { reason: event.reason } : {}),
|
||||
correlationId: event.correlationId,
|
||||
occurredAt: new Date(event.occurredAt),
|
||||
});
|
||||
}
|
||||
266
apps/gateway/src/agent/connector-lease.service.ts
Normal file
266
apps/gateway/src/agent/connector-lease.service.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { ConnectorLeaseCoordinator, normalizeConnectorLease } from '@mosaicstack/agent';
|
||||
import {
|
||||
normalizeConnectorId,
|
||||
normalizeConnectorScopes,
|
||||
normalizeCorrelationId,
|
||||
normalizeLogicalAgentIdentity,
|
||||
normalizeLogicalBindingId,
|
||||
type AcquireConnectorLeaseInput,
|
||||
type ConnectorExecutionGrant,
|
||||
type ConnectorLease,
|
||||
type ConnectorLeaseAuditEvent,
|
||||
type FencedConnectorAdapter,
|
||||
} from '@mosaicstack/types';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||
|
||||
export const CONNECTOR_LEASE_POLICY = Symbol('CONNECTOR_LEASE_POLICY');
|
||||
|
||||
export type ConnectorLeasePolicyAction =
|
||||
| 'lease.acquire'
|
||||
| 'lease.takeover'
|
||||
| 'lease.heartbeat'
|
||||
| 'lease.release'
|
||||
| 'lease.read'
|
||||
| 'grant.issue';
|
||||
|
||||
export interface ConnectorLeaseRequestContext {
|
||||
readonly actorScope: ActorTenantScope;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface GatewayConnectorLeaseRequest {
|
||||
readonly logicalAgentId: string;
|
||||
readonly bindingId: string;
|
||||
readonly connectorId: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly ttlMs: number;
|
||||
}
|
||||
|
||||
export interface GatewayConnectorLeaseTakeoverRequest extends GatewayConnectorLeaseRequest {
|
||||
readonly expectedEpoch: string;
|
||||
}
|
||||
|
||||
export interface GatewayConnectorGrantRequest {
|
||||
readonly lease: ConnectorLease;
|
||||
readonly scopes: readonly string[];
|
||||
readonly ttlMs: number;
|
||||
}
|
||||
|
||||
export interface ConnectorLeasePolicySubject {
|
||||
readonly action: ConnectorLeasePolicyAction;
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly logicalAgentId: string;
|
||||
readonly bindingId: string;
|
||||
readonly connectorId: string;
|
||||
readonly requestedScopes: readonly string[];
|
||||
readonly requestedTtlMs: number | null;
|
||||
}
|
||||
|
||||
export interface ConnectorLeasePolicy {
|
||||
authorize(subject: ConnectorLeasePolicySubject): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** M1 has no concrete cutover policy: unconfigured production use fails closed. */
|
||||
@Injectable()
|
||||
export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy {
|
||||
async authorize(_subject: ConnectorLeasePolicySubject): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway-owned policy surface for durable connector authority and fenced effects. */
|
||||
@Injectable()
|
||||
export class ConnectorLeaseService {
|
||||
private readonly coordinator: ConnectorLeaseCoordinator;
|
||||
|
||||
constructor(
|
||||
@Inject(ConnectorLeaseRepository) private readonly repository: ConnectorLeaseRepository,
|
||||
@Inject(CONNECTOR_LEASE_POLICY) private readonly policy: ConnectorLeasePolicy,
|
||||
) {
|
||||
this.coordinator = new ConnectorLeaseCoordinator(repository);
|
||||
}
|
||||
|
||||
async acquire(
|
||||
request: GatewayConnectorLeaseRequest,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorLease> {
|
||||
const command = this.command(request, context);
|
||||
await this.assertPolicy('lease.acquire', command, context, command.scopes, command.ttlMs);
|
||||
return this.coordinator.acquire({ ...command, correlationId: this.correlation(context) });
|
||||
}
|
||||
|
||||
async takeover(
|
||||
request: GatewayConnectorLeaseTakeoverRequest,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorLease> {
|
||||
const command = this.command(request, context);
|
||||
await this.assertPolicy('lease.takeover', command, context, command.scopes, command.ttlMs);
|
||||
return this.coordinator.takeover({
|
||||
...command,
|
||||
expectedEpoch: request.expectedEpoch,
|
||||
correlationId: this.correlation(context),
|
||||
});
|
||||
}
|
||||
|
||||
async heartbeat(
|
||||
lease: ConnectorLease,
|
||||
ttlMs: number,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorLease> {
|
||||
const normalizedLease = normalizeConnectorLease(lease);
|
||||
await this.assertTenant(normalizedLease, context);
|
||||
await this.assertPolicy(
|
||||
'lease.heartbeat',
|
||||
normalizedLease,
|
||||
context,
|
||||
normalizedLease.scopes,
|
||||
ttlMs,
|
||||
);
|
||||
return this.coordinator.heartbeat({
|
||||
lease: normalizedLease,
|
||||
ttlMs,
|
||||
correlationId: this.correlation(context),
|
||||
});
|
||||
}
|
||||
|
||||
async release(lease: ConnectorLease, context: ConnectorLeaseRequestContext): Promise<void> {
|
||||
const normalizedLease = normalizeConnectorLease(lease);
|
||||
await this.assertTenant(normalizedLease, context);
|
||||
await this.assertPolicy(
|
||||
'lease.release',
|
||||
normalizedLease,
|
||||
context,
|
||||
normalizedLease.scopes,
|
||||
null,
|
||||
);
|
||||
await this.coordinator.release({
|
||||
lease: normalizedLease,
|
||||
correlationId: this.correlation(context),
|
||||
});
|
||||
}
|
||||
|
||||
async current(
|
||||
logicalAgentId: string,
|
||||
bindingId: string,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorLease | null> {
|
||||
const binding = {
|
||||
identity: normalizeLogicalAgentIdentity({
|
||||
tenantId: context.actorScope.tenantId,
|
||||
logicalAgentId,
|
||||
}),
|
||||
bindingId: normalizeLogicalBindingId(bindingId),
|
||||
connectorId: 'gateway',
|
||||
};
|
||||
await this.assertPolicy('lease.read', binding, context, [], null);
|
||||
return this.coordinator.current(binding);
|
||||
}
|
||||
|
||||
async issueGrant(
|
||||
request: GatewayConnectorGrantRequest,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorExecutionGrant> {
|
||||
const lease = normalizeConnectorLease(request.lease);
|
||||
await this.assertTenant(lease, context);
|
||||
const scopes = normalizeConnectorScopes(request.scopes);
|
||||
await this.assertPolicy('grant.issue', lease, context, scopes, request.ttlMs);
|
||||
return this.coordinator.issueGrant({
|
||||
lease,
|
||||
scopes,
|
||||
ttlMs: request.ttlMs,
|
||||
correlationId: this.correlation(context),
|
||||
});
|
||||
}
|
||||
|
||||
async executeGrant<TInput, TOutput>(
|
||||
grant: ConnectorExecutionGrant,
|
||||
requiredScope: string,
|
||||
input: TInput,
|
||||
adapter: FencedConnectorAdapter<TInput, TOutput>,
|
||||
): Promise<TOutput> {
|
||||
return this.coordinator.executeGrant(grant, requiredScope, input, adapter);
|
||||
}
|
||||
|
||||
private command(
|
||||
request: GatewayConnectorLeaseRequest,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Omit<AcquireConnectorLeaseInput, 'correlationId'> {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity({
|
||||
tenantId: context.actorScope.tenantId,
|
||||
logicalAgentId: request.logicalAgentId,
|
||||
}),
|
||||
bindingId: normalizeLogicalBindingId(request.bindingId),
|
||||
connectorId: normalizeConnectorId(request.connectorId),
|
||||
scopes: normalizeConnectorScopes(request.scopes),
|
||||
ttlMs: request.ttlMs,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertTenant(
|
||||
lease: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<void> {
|
||||
if (lease.identity.tenantId !== context.actorScope.tenantId) {
|
||||
await this.recordPolicyDenial(
|
||||
{
|
||||
identity: {
|
||||
tenantId: context.actorScope.tenantId,
|
||||
logicalAgentId: 'untrusted',
|
||||
},
|
||||
bindingId: 'untrusted',
|
||||
connectorId: 'untrusted',
|
||||
},
|
||||
context,
|
||||
);
|
||||
throw new ForbiddenException('Connector authority tenant scope denied');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPolicy(
|
||||
action: ConnectorLeasePolicyAction,
|
||||
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
requestedScopes: readonly string[],
|
||||
requestedTtlMs: number | null,
|
||||
): Promise<void> {
|
||||
const allowed = await this.policy.authorize({
|
||||
action,
|
||||
actorId: context.actorScope.userId,
|
||||
tenantId: subject.identity.tenantId,
|
||||
logicalAgentId: subject.identity.logicalAgentId,
|
||||
bindingId: subject.bindingId,
|
||||
connectorId: subject.connectorId,
|
||||
requestedScopes: Object.freeze([...requestedScopes]),
|
||||
requestedTtlMs,
|
||||
});
|
||||
if (!allowed) {
|
||||
await this.recordPolicyDenial(subject, context);
|
||||
throw new ForbiddenException('Connector authority policy denied');
|
||||
}
|
||||
}
|
||||
|
||||
private async recordPolicyDenial(
|
||||
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<void> {
|
||||
const event: ConnectorLeaseAuditEvent = {
|
||||
identity: subject.identity,
|
||||
bindingId: subject.bindingId,
|
||||
connectorId: subject.connectorId,
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'policy_denied',
|
||||
correlationId: this.correlation(context),
|
||||
occurredAt: new Date().toISOString(),
|
||||
};
|
||||
await this.repository.recordAudit(event);
|
||||
}
|
||||
|
||||
private correlation(context: ConnectorLeaseRequestContext): string {
|
||||
return normalizeCorrelationId(context.correlationId);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@
|
||||
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
||||
**Phase:** Execution (workstream W1 in planning-complete state)
|
||||
**Current Workstream:** W1 — Federation v1
|
||||
**Progress:** 0 / 3 declared workstreams complete (more workstreams will be declared as scope is refined)
|
||||
**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined)
|
||||
**Status:** active (continuous since 2026-03-13)
|
||||
**Last Updated:** 2026-07-14 (W3 Native Kanban/SOT canon independently approved under issue #751)
|
||||
**Last Updated:** 2026-04-19 (manifest authored at the rollup level; install-ux-v2 archived; W1 federation planning landed via PR #468)
|
||||
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
||||
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
||||
|
||||
@@ -67,12 +67,11 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
||||
|
||||
## Workstreams
|
||||
|
||||
| # | ID | Name | Status | Manifest | Notes |
|
||||
| --- | ---- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
||||
| W3 | KBN | Native Kanban and canonical task SOT | planning-complete | [docs/native-kanban-sot/MISSION-MANIFEST.md](./native-kanban-sot/MISSION-MANIFEST.md) | P0–P3; issue #751; implementation held until canon merge |
|
||||
| W4+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||
| # | ID | Name | Status | Manifest | Notes |
|
||||
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
||||
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||
|
||||
### Likely Additional Workstreams (Not Yet Declared)
|
||||
|
||||
|
||||
167
docs/PRD.md
167
docs/PRD.md
@@ -175,6 +175,37 @@ Delivery uses five gated milestones: runtime contracts/security; Pi service/stat
|
||||
|
||||
---
|
||||
|
||||
## Mos Runtime Portability Workstream (MOS-PORT)
|
||||
|
||||
### Problem and Objective
|
||||
|
||||
Mos is currently identified partly by a harness-native session and communication process. Replacement/rebinding exists, but no gateway-enforced logical identity or fencing prevents a stale harness from continuing to reply or execute effects after takeover.
|
||||
|
||||
The objective is to make Mos a server-derived logical Mosaic identity whose authority can move safely among runtime connectors. The gateway owns identity, lease, policy, and audit; harnesses remain replaceable adapters.
|
||||
|
||||
### M1 Requirements
|
||||
|
||||
1. `MOS-PORT-ID-001`: Define a normalized logical-agent identity independent of Claude Code, Pi, Codex, tmux, Matrix, and provider-native session IDs.
|
||||
2. `MOS-PORT-LEASE-001`: Persist one exclusive connector lease per tenant/logical-agent/binding with CAS acquisition, monotonic fencing epoch, TTL, heartbeat, explicit release, and takeover.
|
||||
3. `MOS-PORT-FENCE-001`: Bind every connector dispatch/execution grant to the current server-derived tenant, logical identity, binding, connector, scopes, expiry, and lease epoch.
|
||||
4. `MOS-PORT-FENCE-002`: Reject and audit stale, expired, forged, cross-tenant, cross-binding, and unauthorized grants before connector, channel, provider, or tool side effects.
|
||||
5. `MOS-PORT-OBS-001`: Emit credential-safe correlation/audit events for lease acquire, renew, takeover, reject, release, and expiry.
|
||||
6. `MOS-PORT-ARCH-001`: Runtime/provider adapters consume normalized lease context without adding harness-native schemas to Mosaic core.
|
||||
|
||||
### M1 Acceptance Criteria
|
||||
|
||||
1. `AC-MOS-PORT-01`: Two contenders for one binding cannot simultaneously hold current authority under concurrency.
|
||||
2. `AC-MOS-PORT-02`: Successful takeover increments the fencing epoch and every operation from the old epoch fails closed before side effects.
|
||||
3. `AC-MOS-PORT-03`: Gateway/database restart preserves lease and epoch state; expired leases can be recovered only through the authorized takeover path.
|
||||
4. `AC-MOS-PORT-04`: Cross-tenant, cross-agent, cross-binding, forged, and expired lease/grant cases are denied and audited.
|
||||
5. `AC-MOS-PORT-05`: Unit, migration, repository close/reopen, concurrency, abuse, gateway integration, independent security review, CI, and documentation gates pass.
|
||||
|
||||
### Deferred to Later #754 Milestones
|
||||
|
||||
Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete Claude/Pi/Codex adapters, channel cutover, and full cross-harness failover/rollback E2E are explicitly out of M1 scope.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-Level System Diagram
|
||||
@@ -1100,139 +1131,3 @@ 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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
# Documentation Sitemap
|
||||
|
||||
## Fleet declarative configuration management
|
||||
## Mos runtime portability
|
||||
|
||||
- [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.
|
||||
- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md)
|
||||
- [M1 connector lease operations](guides/mos-connector-lease-operations.md)
|
||||
|
||||
## Tess interaction agent
|
||||
|
||||
|
||||
@@ -14,12 +14,10 @@
|
||||
|
||||
## Workstream Rollup
|
||||
|
||||
| id | status | workstream | progress | tasks file | notes |
|
||||
| --- | ----------------- | ------------------------ | ----------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
||||
| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges |
|
||||
| W4 | 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 |
|
||||
| id | status | workstream | progress | tasks file | notes |
|
||||
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
||||
|
||||
## Cross-Cutting Tracking
|
||||
|
||||
@@ -93,64 +91,3 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
## #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.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
49
docs/architecture/mos-runtime-portability-m1.md
Normal file
49
docs/architecture/mos-runtime-portability-m1.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
||||
|
||||
## Boundary
|
||||
|
||||
M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The normalized identity is:
|
||||
|
||||
```text
|
||||
(tenant_id, logical_agent_id, binding_id)
|
||||
```
|
||||
|
||||
`logical_agent_id` is a server-owned stable identifier. A connector is a replaceable holder of a lease for one binding; it is not the agent identity.
|
||||
|
||||
## Durable lease model
|
||||
|
||||
PostgreSQL table `logical_agent_connector_leases` has one unique row per identity/binding tuple. The current row records:
|
||||
|
||||
- an opaque lease UUID;
|
||||
- connector ID and normalized allowed scopes;
|
||||
- a positive decimal fencing epoch stored as PostgreSQL `bigint`;
|
||||
- acquired, heartbeat, expiry, release, and update timestamps.
|
||||
|
||||
Initial acquisition is insert-only. An existing active row causes `lease_held`. An expired or released row causes `takeover_required`; ordinary acquisition cannot recover it. Authorized takeover uses compare-and-swap against the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch.
|
||||
|
||||
The companion `connector_lease_audit_log` is append-only metadata. It stores lifecycle event, outcome/reason, identity/binding/connector, epoch, correlation ID, and timestamp. It deliberately excludes scopes, grant objects, payloads, approval references, tokens, and credentials.
|
||||
|
||||
## Execution grants
|
||||
|
||||
`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. Defense-in-depth caps leases at 5 minutes and grants at 30 seconds by default; constructor options may tighten these limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, and epoch.
|
||||
|
||||
Validation occurs immediately before adapter invocation and rereads PostgreSQL. The adapter receives only `ConnectorExecutionContext`; harness-native schemas remain behind the adapter. Validation denies:
|
||||
|
||||
- grants not minted by the current gateway process (including cloned/forged objects);
|
||||
- expired grants or leases;
|
||||
- released leases;
|
||||
- stale epochs or replaced connector/lease UUIDs;
|
||||
- missing/cross-tenant/cross-agent/cross-binding leases;
|
||||
- scopes not authorized by both grant and current lease.
|
||||
|
||||
A gateway restart intentionally invalidates process-local grants. The durable lease and epoch survive, and a fresh grant may be issued only after current-lease and gateway-policy validation.
|
||||
|
||||
## Concurrency and side-effect rule
|
||||
|
||||
The database CAS determines the sole current holder. A successful takeover makes every old-epoch validation fail. Connector adapters must consume and propagate the normalized lease epoch/context so downstream effect boundaries can also fence races that occur after gateway validation.
|
||||
|
||||
M1 does not provide exactly-once receipts or a side-effect journal. Those remain later #754 work; callers must not infer exactly-once delivery from lease fencing.
|
||||
|
||||
## Extension boundary
|
||||
|
||||
`ConnectorLeaseService` is the gateway-owned policy surface. Every policy decision receives the normalized requested scopes and TTL (or explicit `null` where no TTL applies), so a concrete policy can enforce least privilege and duration limits. Its production default policy denies every lease/grant operation until a server-configured connector policy is supplied. No M1 HTTP endpoint accepts caller-controlled tenant or logical identity, and no concrete Claude/Pi/Codex adapter or channel cutover is included.
|
||||
43
docs/guides/mos-connector-lease-operations.md
Normal file
43
docs/guides/mos-connector-lease-operations.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Mos Connector Lease Operations — M1
|
||||
|
||||
## Operational status
|
||||
|
||||
M1 installs the durable schema and gateway policy/adapter boundary. It does **not** activate a connector, expose a lease administration endpoint, or cut over a channel. The default gateway connector-lease policy is deny-all until a later work package supplies an authorized server-side policy and concrete adapter.
|
||||
|
||||
## Events to monitor
|
||||
|
||||
Use correlation IDs to follow `connector_lease_audit_log` events:
|
||||
|
||||
| Event | Meaning |
|
||||
| ---------- | --------------------------------------------------------------------- |
|
||||
| `acquire` | First holder inserted for an unused binding |
|
||||
| `renew` | Current holder heartbeat extended the TTL |
|
||||
| `takeover` | Authorized CAS replaced the holder and incremented epoch |
|
||||
| `release` | Current holder explicitly relinquished authority |
|
||||
| `expiry` | An expired current lease was observed |
|
||||
| `reject` | Policy, CAS, expiry, scope, or fencing validation denied an operation |
|
||||
|
||||
Audit data is metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output.
|
||||
|
||||
## Incident checks
|
||||
|
||||
For suspected duplicate/stale connector effects:
|
||||
|
||||
1. Correlate the attempted operation with its `reject`, `takeover`, or `expiry` event.
|
||||
2. Compare the current row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context.
|
||||
3. Treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative. Do not retry it as the old holder.
|
||||
4. Recovery uses the authorized takeover path with the observed expected epoch. Ordinary acquire is intentionally rejected for expired/released rows.
|
||||
5. If an external effect may already have happened, preserve evidence and do not assume lease fencing provides exactly-once replay safety.
|
||||
|
||||
## Migration and rollback safety
|
||||
|
||||
Migration `0016_salty_morlocks.sql` is additive: it creates two new tables and indexes without modifying existing authorization/session tables. Before rollout, normal database backup and migration verification still apply. Rolling application code back leaves unused additive tables in place; dropping tables is not part of automated rollback because it would destroy lease/audit evidence.
|
||||
|
||||
## Security constraints
|
||||
|
||||
- Tenant comes from authenticated gateway context, never a connector request field.
|
||||
- Logical agent, binding, connector, and scope identifiers use normalized constrained forms.
|
||||
- Takeover requires explicit gateway policy authorization and an expected epoch.
|
||||
- Default defense-in-depth TTL caps are 5 minutes for leases and 30 seconds for grants; policy may enforce stricter limits.
|
||||
- Validation and rejection audit complete before adapter side effects.
|
||||
- Existing authz and exact-action approval controls remain additional required gates; a valid connector lease does not bypass them.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Documentation Completion Checklist — Native Kanban/SOT Canon
|
||||
|
||||
**Tracking:** Mosaic Stack issue #751
|
||||
**Scope:** Requirements and contract publication only; runtime implementation follows in separate slices.
|
||||
|
||||
## Required artifacts
|
||||
|
||||
- [x] Project `docs/PRD.md` exists; the workstream requirements refine its task/project-management scope.
|
||||
- [x] Canonical workstream requirements published at `docs/requirements/native-kanban-sot.md`.
|
||||
- [x] Mission manifest, task decomposition, frozen shared contract, and typed contract declarations included.
|
||||
- [x] `docs/SITEMAP.md` updated.
|
||||
- [x] Independent initial review and final GO report stored under `docs/reports/native-kanban-sot/`.
|
||||
- [x] Task scratchpad stored under `docs/scratchpads/`.
|
||||
- [ ] User/Admin/Developer guides — N/A for canon-only publication; required in implementation slices that change behavior or operations.
|
||||
- [ ] OpenAPI and endpoint index — N/A until KBN-105 freezes implementation-ready endpoint contracts.
|
||||
|
||||
## Structural and root hygiene
|
||||
|
||||
- [x] Canonical requirements are under `docs/requirements/`.
|
||||
- [x] Workstream artifacts are under `docs/native-kanban-sot/`.
|
||||
- [x] Review reports are under `docs/reports/native-kanban-sot/`.
|
||||
- [x] No new unscoped document was added to the `docs/` root.
|
||||
- [x] Root mission/task rollups link to the workstream.
|
||||
|
||||
## Review gate
|
||||
|
||||
- [x] Author and independent reviewer are different agents.
|
||||
- [x] KCR-001–016 closure was independently verified.
|
||||
- [x] Ultron final gate returned GO with zero BLOCKER/HIGH findings.
|
||||
- [x] Formatter, lint, typecheck, strict contract TypeScript, link, scope, and invariant publication validation passed in the current Stack toolchain.
|
||||
- [ ] PR review, CI, squash merge, and issue closure remain required before publication completion.
|
||||
|
||||
## Publishing
|
||||
|
||||
- [x] Canonical source remains in-repository.
|
||||
- [x] No external publishing platform is required for this internal architecture contract.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Native Kanban/SOT Canon
|
||||
|
||||
**Status:** KCR-001–016 independently cleared; canonical publication is in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
**Date:** 2026-07-14
|
||||
**Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied.
|
||||
|
||||
## Artifacts
|
||||
|
||||
| Artifact | Purpose |
|
||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0–P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria |
|
||||
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership |
|
||||
| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0–P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization |
|
||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||
| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults |
|
||||
| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations |
|
||||
| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals |
|
||||
| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001–016 findings that blocked the first draft |
|
||||
| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
|
||||
| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
|
||||
|
||||
## Recommended USC lane partition
|
||||
|
||||
| Lane | Natural seam | Exclusive ownership |
|
||||
| ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **coder2** | Schema + migrations + recovery slice | Unified Drizzle schema, migration SQL/meta/journal/tests, then recovery parser/mechanism/runbook files |
|
||||
| **coder3** | Domain + Gateway + MCP server | Workspace-safe repositories, DTOs/controllers/services, exact `apps/gateway/src/mcp/**` files, health proof, proposals, Coordinator persistence adapter |
|
||||
| **coder4** | Pure Coordinator + tooling | `packages/coord` mechanical engine, CLI/MCP consumers, generated projection, one-way importer and cutover tooling; lane-serialized internally |
|
||||
| **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI |
|
||||
| **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority |
|
||||
|
||||
The safe order is KBN-010 → KBN-100 → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files.
|
||||
|
||||
## Recovery defaults
|
||||
|
||||
| Tier | RPO / RTO | WAL / PITR | Base backup | Restore / break-glass | Off-cluster |
|
||||
| -------------- | ------------ | ------------------- | ----------- | ----------------------- | ----------------------------------------------- |
|
||||
| Lite | 24h / 24h | disabled / disabled | daily | quarterly / annual | encrypted separate target |
|
||||
| Standard | 1h / 8h | q15m / 14d | daily | quarterly / semiannual | encrypted separate object storage |
|
||||
| High-assurance | **15m / 4h** | **q5m / 35d** | **daily** | **monthly / quarterly** | **encrypted base+WAL, separate failure domain** |
|
||||
|
||||
These knobs affect recovery posture only. PostgreSQL remains the sole writable SOT in every tier. Fail-closed writes, generated-file non-authority, attributable post-recovery proposals, non-LLM Coordinator limits, and Certifier final-gate/no-merge authority are fixed for every tier.
|
||||
|
||||
## Non-blocking implementation sub-decisions for Mos
|
||||
|
||||
The source plan and ratified seven decisions resolve all build-blocking product choices. The following implementation-local selections remain for the owning slices/Mos and must not weaken v1:
|
||||
|
||||
1. Exact PostgreSQL write-health probe SQL and bounded proof lifetime; authority and failures are frozen.
|
||||
2. Dependency-cycle serialization mechanism (recursive CTE plus transaction/advisory lock or equivalent); required behavior is frozen.
|
||||
3. Whether RLS lands in the first migration or immediately after the tested session-context pattern; workspace constraints/repository authorization are required from migration one.
|
||||
4. Concrete off-cluster backup provider/bucket and selected production recovery tier; High-assurance minima are frozen if selected.
|
||||
5. Cutover reconciliation thresholds and stabilization duration, to be owner-approved before P3 execution.
|
||||
|
||||
None authorizes a second writer, dual sync, LLM scheduling, Coordinator gate waiver/merge, or Certifier merge authority.
|
||||
|
||||
## Publication validation evidence
|
||||
|
||||
- Concrete TypeScript contracts are formatted with repository Prettier.
|
||||
- All four contracts pass strict TypeScript no-emit checking against the current Stack Drizzle toolchain.
|
||||
- Contract remediation and KCR-001–016 traceability are recorded in the issue scratchpad and linked review reports.
|
||||
- Independent re-review returned GO with KCR-001–016 closed; implementation remains held until canon merge and the dependency-ordered KBN prerequisites complete.
|
||||
@@ -1,195 +0,0 @@
|
||||
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3
|
||||
|
||||
**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
**Date:** 2026-07-14
|
||||
**Human decision owner:** Jason
|
||||
**Orchestrator/publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage)
|
||||
**Execution topology:** USC web1, partitioned across collision-free GPT coder2/3/4/5 lanes
|
||||
**Canonical requirements:** [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md)
|
||||
**Frozen integration contract:** `SHARED-CONTRACT.md` and `contracts/*.v1.ts`
|
||||
|
||||
## 1. Mission statement
|
||||
|
||||
Extend current `mosaicstack/stack` main into the sole native control plane for workspace-scoped project, mission, milestone, task, dependency, assignment, lease, approval, evidence, and audit state. First deliver a thin writable Kanban/List vertical slice; then add deterministic mechanical coordination and execute a one-way migration/cutover from jarvis-brain/Vikunja project/task stores.
|
||||
|
||||
Success means every user, agent, orchestrator, specialist, and UI sees and mutates the same PostgreSQL aggregate revisions through typed Gateway commands, with no writable fallback and no hidden second authority.
|
||||
|
||||
## 2. Scope boundaries
|
||||
|
||||
### In scope
|
||||
|
||||
- Current Drizzle/PostgreSQL schema extension and migrations.
|
||||
- Workspace tenancy and authorization from the first migration.
|
||||
- Projects, missions, milestones, tasks, normalized tags, dependencies, assignments, durable execution/quarantine state, links, immutable artifacts/evidence joins, outage change proposals, events, approvals, leases, checkpoints, and transactional outbox.
|
||||
- NestJS Gateway queries and explicit lifecycle commands.
|
||||
- MCP/CLI agent surfaces and generated read-only projections.
|
||||
- Thin writable Next.js Tasks Kanban/List, task detail, minimal Projects CRUD, filters, dependency readiness, ownership/lease separation, and audit timeline.
|
||||
- Non-LLM Mechanical Coordinator eligibility, proposal, approval-policy, lease/fence, heartbeat, retry, expiry, quarantine, and restart recovery.
|
||||
- Planning, Enhance, Coder, Review, SecReview, PR-Monitor, and Certifier role/gate representation.
|
||||
- One-way shadow importer, reconciliation, write freeze, final delta, cutover, rollback package, and legacy read-only stabilization.
|
||||
- Recovery-posture configuration and health-state/fail-closed contract.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Greenfield services, Prisma runtime revival, or jarvis-brain flat files as runtime storage.
|
||||
- Writable Markdown/JSON/Valkey/browser/provider fallback.
|
||||
- Gitea issue/PR replacement or generic bidirectional provider sync.
|
||||
- Calendar, email, GLPI cache, CRM, billing, time tracking, personal-brain migration.
|
||||
- LLM scheduling or scope interpretation by the Coordinator.
|
||||
- Autonomous gate waiver, certification, merge, release, deployment, or issue closure by Coordinator.
|
||||
- Merge authority for Certifier.
|
||||
- P4 full portfolio/mission designer and P5 fleet-scale policy unless separately released.
|
||||
|
||||
## 3. Fixed invariants
|
||||
|
||||
Every deployment MUST preserve all of the following:
|
||||
|
||||
1. PostgreSQL is the sole writable SOT.
|
||||
2. Drizzle on current stack main is the only persistence foundation.
|
||||
3. Mutations fail closed when DB write-health cannot be proven `healthy`.
|
||||
4. No file, Valkey, browser, queue, provider, or human note becomes a fallback writer.
|
||||
5. `TASKS.md`, `mission.json`, and every file export are generated, read-only, non-authoritative, and never import sources.
|
||||
6. Human outage notes become attributable post-recovery proposals only.
|
||||
7. Workspace is the hard tenant; Team is intra-workspace authorization.
|
||||
8. Valkey is expendable; PostgreSQL owns state, leases, fencing, audit, and outbox.
|
||||
9. Mechanical Coordinator is deterministic/non-LLM and cannot invent scope, waive gates, certify, or merge.
|
||||
10. Certifier is the final independent quality gate and has no merge authority.
|
||||
11. Mutations use idempotency and optimistic aggregate versions; worker commands also require a current fencing token.
|
||||
12. Recovery tier changes only backup/recovery posture, never authority or gate semantics.
|
||||
|
||||
## 4. Configurable recovery posture
|
||||
|
||||
Deployments select Lite, Standard, or High-assurance defaults from [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md) and `contracts/recovery-posture.v1.ts`. Configurable fields are limited to:
|
||||
|
||||
- backup/base-backup cadence;
|
||||
- RPO and RTO targets;
|
||||
- PITR retention;
|
||||
- WAL archive cadence;
|
||||
- restore-test frequency;
|
||||
- break-glass drill frequency;
|
||||
- encrypted off-cluster storage.
|
||||
|
||||
High-assurance defaults are fixed reference values: RPO 15 minutes, RTO 4 hours, encrypted off-cluster WAL every 5 minutes with 35-day PITR, daily base backup, monthly restore test, and quarterly break-glass drill.
|
||||
|
||||
## 5. Canonical role map
|
||||
|
||||
```text
|
||||
User
|
||||
↓ objectives, constraints, ratified decisions
|
||||
Interaction Layer
|
||||
↓ workspace/project context; no scheduling authority
|
||||
Portfolio Orchestrator
|
||||
↓ approved mission, cross-project priority/capacity
|
||||
Project Sub-Orchestrator
|
||||
↓ decomposition, DAG, acceptance, release, routing policy, overrides
|
||||
Gateway
|
||||
↓ authenticated/authorized typed commands
|
||||
Project/Task Domain Services
|
||||
↓ transactional state + semantic event + outbox
|
||||
Mechanical Coordinator
|
||||
↓ deterministic eligibility/proposal/lease/fence/retry/quarantine
|
||||
Specialists
|
||||
Planning → Enhance → Coder → Review → conditional SecReview → remediation
|
||||
↓ complete evidence bundle
|
||||
Certifier
|
||||
↓ final pass/reject/escalate; NO merge authority
|
||||
Project Sub-Orchestrator / control plane
|
||||
↓ merge authority after all gates
|
||||
Post-merge validation
|
||||
```
|
||||
|
||||
### Authority table
|
||||
|
||||
| Role/layer | Owns | Explicitly cannot do |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| User | Objectives, constraints, Jason-owned decisions | Direct DB/file authority bypass |
|
||||
| Interaction | Conversation and context resolution | Schedule, approve, lease, certify |
|
||||
| Portfolio Orchestrator | Mission approval, cross-project priority/capacity/global holds | Implement or self-certify specialist work |
|
||||
| Project Sub-Orchestrator | Task decomposition/DAG/acceptance, release to ready, routing policy, overrides, remediation, merge go-ahead | Bypass required independent gates |
|
||||
| Gateway | Identity, tenancy, DTO validation, commands, state-machine enforcement | Accept file edits or client SQL as mutations |
|
||||
| Domain services | Transactional business invariants, semantic events/outbox | Depend on Valkey/files for committed truth |
|
||||
| Mechanical Coordinator | Eligibility, dependencies, proposal, approved routing, lease/fence, heartbeat, retry/quarantine | Invent/alter scope, waive gates, certify, merge |
|
||||
| Specialists | Bounded planning/implementation/review artifacts under a task lease | Modify another lane's owned files or self-approve |
|
||||
| Certifier | Final independent evidence/traceability/gate decision | Merge, close provider issue, release, waive policy |
|
||||
|
||||
## 6. Gate model
|
||||
|
||||
### Mandatory gates
|
||||
|
||||
1. Requirements/contract freeze before parallel implementation.
|
||||
2. P0 schema/authority threat model and tenant isolation review.
|
||||
3. Author and reviewer MUST be different principals/sessions.
|
||||
4. Functional review validates requirements, endpoint registry, concurrency, and negative paths.
|
||||
5. **Mandatory SecReview (`secrev`)** for any auth, authorization, tenant, service-token, secret, database schema/migration, data-integrity, import/cutover, audit, lease/fencing, recovery, or destructive-retirement surface.
|
||||
6. Review findings enter bounded remediation owned by the implementation lane.
|
||||
7. Raising reviewer re-verifies remediation.
|
||||
8. Certifier performs the final independent evidence and traceability gate.
|
||||
9. Merge authority remains with `mos-claude`/Project Sub-Orchestrator control plane after gates pass.
|
||||
10. Post-merge CI and situational validation must be terminal green before closure.
|
||||
|
||||
### Gate outcomes
|
||||
|
||||
- **PASS:** evidence complete; next authority may proceed.
|
||||
- **REJECT:** findings are explicit and route to remediation.
|
||||
- **ESCALATE:** policy/owner decision required; no implicit waiver.
|
||||
|
||||
No role can transform a missing gate into a warning by changing status, editing a projection, or writing Valkey.
|
||||
|
||||
## 7. Slice ownership rules
|
||||
|
||||
1. USC web1 is the sole execution environment; coder2/3/4/5 are independent bounded lanes under Mos.
|
||||
2. Every slice has one named file-tree owner and an explicit IN/OUT boundary in `TASKS.md`.
|
||||
3. Two active slices MUST NOT edit the same source file, migration file, generated snapshot, lockfile, or API contract.
|
||||
4. coder2 exclusively owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, migration journal/meta/tests, then its disjoint recovery-parser/runbook slice. All schema requests serialize through coder2.
|
||||
5. Frozen `contracts/*.v1.ts` are read-only inputs during implementation. Contract changes require Mos approval, a version bump/amendment, and coordinated rebase before work resumes.
|
||||
6. coder3 exclusively owns Gateway DTO/controllers/services and the enumerated `apps/gateway/src/mcp/**` server files. coder4 owns CLI/projection clients and never edits MCP server files. Web consumers use the exact KBN-105 endpoint/DTO freeze.
|
||||
7. coder4 executes one lane order: CLI/projection → pure Coordinator → importer → cutover. The pure Coordinator under `packages/coord` does not load IDs or access DB, Gateway, Valkey, recovery I/O, or web files; coder3 owns the persistence/service adapter.
|
||||
8. Migration/import tooling calls Gateway/migration-only approved ports and does not add a second database model.
|
||||
9. Each lane commits only its owned files and reports any needed cross-slice change as a contract-change request instead of editing another lane's tree.
|
||||
10. Cross-review is mandatory: no lane reviews its own changes. Recommended ring is coder2 ← coder5, coder3 ← coder2, coder4 ← coder3, coder5 ← coder4, followed by independent SecReview where triggered and Certifier final.
|
||||
11. Integration-only edits are a separate serialized slice after component lanes are green; no opportunistic merge-conflict resolution may alter semantics.
|
||||
|
||||
## 8. Delivery phases and exit gates
|
||||
|
||||
### P0 — Canon and authority foundation
|
||||
|
||||
- Publish this canon, frozen schema/ports/health/recovery contracts, threat model, authorization matrix, exact endpoint/DTO registry, concrete current-main field-by-field migration map, and standards amendment.
|
||||
- Build hold remains active until independent author≠reviewer re-review returns GO on health proof/failures, approval binding, fencing, tenant relationships, proposals, migration map, slice ordering/API freeze, recovery validation, and vocabulary alignment.
|
||||
- Exit: no unresolved second writer or contract blocker, tenant boundary frozen, all seven decisions traceable, and independent re-review GO recorded.
|
||||
|
||||
### P1 — Thin native MVP
|
||||
|
||||
- Schema/migration, tenant-safe Gateway, CLI/MCP/projection, writable Kanban/List/Projects, dependencies/readiness/audit.
|
||||
- Exit: same revision across web/CLI/MCP/projection; cross-workspace tests fail closed; generated files cannot mutate state.
|
||||
|
||||
### P2 — Mechanical coordination
|
||||
|
||||
- Agent/session registry, deterministic engine, approval queue, PostgreSQL leases/fencing/checkpoints/outbox, retry/quarantine, operations UI.
|
||||
- Exit: one lease winner, stale tokens rejected, dependencies/approvals enforced, DB/Valkey fault semantics proven, Certifier gate has no merge authority.
|
||||
|
||||
### P3 — Shadow migration and cutover
|
||||
|
||||
- Importer, lineage, reconciliation, reviewer UI, write freeze, final delta, Gateway switch, legacy read-only, stabilization and rollback package.
|
||||
- Exit: signed reconciliation, zero active legacy writers, scoped Gateway identities, imported backlog cannot dispatch accidentally.
|
||||
|
||||
## 9. Evidence required for mission closure
|
||||
|
||||
- Requirement-to-test/evidence matrix.
|
||||
- Schema/migration and N-1 rolling-deploy proof.
|
||||
- Cross-workspace API/repository/import/Coordinator negative tests.
|
||||
- Health-state and fail-closed fault injection.
|
||||
- Valkey-loss/outbox replay and Coordinator restart tests.
|
||||
- Concurrent lease and stale fencing tests.
|
||||
- Endpoint-registry alignment across web/CLI/MCP/Gateway.
|
||||
- Accessible real-Gateway Kanban journeys.
|
||||
- Generated projection tamper/no-import proof.
|
||||
- One-way migration dry-run/apply/verify and field reconciliation.
|
||||
- Author-independent functional review and required SecReview.
|
||||
- Certifier final decision and evidence bundle.
|
||||
- Merged main SHA, terminal green CI, closed linked task/issue, and post-merge situational validation under orchestrator ownership.
|
||||
|
||||
## 10. Change control
|
||||
|
||||
This manifest is derived from the ratified source plan. Any change to SOT authority, workspace tenancy, fixed statuses, Coordinator/Certifier authority, health-state semantics, schema v1, migration direction, or recovery-tier field set is a contract change. Contract changes require Jason/Mos authorization and cannot be inferred by an implementation lane.
|
||||
|
||||
No coder lane may start while the build hold is active. KBN-010 must complete before KBN-100; KBN-105 exact endpoint/DTO freeze must complete before any API consumer implementation.
|
||||
@@ -1,219 +0,0 @@
|
||||
# Native Kanban/SOT — Remediated Shared Contract v1
|
||||
|
||||
**Status:** INDEPENDENT REVIEW GO; freezes as v1 when issue #751 canon merges to `main`
|
||||
**Version:** 1.0.0-rc.3
|
||||
**Date:** 2026-07-14
|
||||
**Change authority:** Mosaic control plane/Jason only
|
||||
|
||||
## 1. Authority
|
||||
|
||||
Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied.
|
||||
|
||||
## 2. Health proof and exact failures
|
||||
|
||||
`KanbanHealthResponseV1` is a discriminated union:
|
||||
|
||||
| State | read | write | Capability |
|
||||
| -------------------- | ----: | ----: | --------------------------------------------------- |
|
||||
| `healthy` | true | true | reads; public state still cannot authorize mutation |
|
||||
| `read-only-degraded` | true | false | reads only |
|
||||
| `write-unavailable` | false | false | diagnostics only |
|
||||
|
||||
Every response has `checkedAt`, `validUntil`, `policyRevision`; contradictory booleans fail validation.
|
||||
|
||||
For a mutation, Gateway opens the PostgreSQL transaction, executes the live write probe on that transaction/connection, mints the internal branded `PostgresWriteHealthProofV1`, and revalidates time/policy/transaction identity immediately before mutation. Public REST/MCP/CLI DTOs never accept health/proof fields. Valkey/caller assertions cannot mint proof. Pure Coordinator takes `KanbanEvaluationContextV1`; persistence takes `InternalKanbanMutationContextV1` or probes internally.
|
||||
|
||||
| Case | HTTP | Frozen result | Retry |
|
||||
| ---------------------------- | --------------------------: | ------------------------------------------------------------------- | -------------------- |
|
||||
| degraded write | 503 | `KANBAN_WRITE_HEALTH_UNPROVEN`, `read-only-degraded`, `not_applied` | false |
|
||||
| write unavailable | 503 | `KANBAN_WRITE_UNAVAILABLE`, `write-unavailable`, `not_applied` | false |
|
||||
| version conflict | 409 | `AGGREGATE_VERSION_CONFLICT`, actual version, `not_applied` | false |
|
||||
| timeout/unreachable | timeout/502/504 | `retryable_transport_error`, `unknown` | same idempotency key |
|
||||
| stale fence/session/approval | coordinator rejection union | `not_applied` | false |
|
||||
|
||||
Required negatives: contradictory state, expired/policy-mismatched/wrong-transaction proof, Valkey-only health, forged healthy, and exhaustive non-cross-mapping of 503 vs 502/504/timeout vs 409.
|
||||
|
||||
## 3. Canonical schema invariants
|
||||
|
||||
Complete declaration: `contracts/kanban-schema.v1.ts`.
|
||||
|
||||
- Tables: tenant/identity (`workspaces`, members, teams/members, agents/sessions); planning (`projects`, `milestones`, current-milestone join, `missions`, mission-milestones, `tasks`, normalized tags, dependencies); orchestration (`task_assignments`, durable execution state, leases, checkpoints/evidence); governance (`change_proposals`, immutable artifacts/evidence, events, approvals, outbox, external links).
|
||||
- Task statuses: `backlog | ready | in_progress | blocked | in_review | done | cancelled`.
|
||||
- Assignment states everywhere: `awaiting_approval | policy_pre_authorized | approved | rejected | leased | released | expired | superseded`.
|
||||
- Specialist roles everywhere: `planning | enhance | coder | review | security-review | pr-monitor | certifier`.
|
||||
- Owner uses exactly-one user/team; assignment principal exactly-one user/team/agent; users require active membership; agent/session and all evidence are workspace-bound.
|
||||
- Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations.
|
||||
- Dependency identity is workspace+predecessor+successor independent of type.
|
||||
- Approval evidence and checkpoint evidence are workspace-scoped joins to immutable artifacts, never JSON ID arrays.
|
||||
- Proposal audit links are composite relations: `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` reference `task_events(workspace_id, id)` with RESTRICT deletion.
|
||||
- Assignment is persisted with task/version, exact target/session, expiry/state/policy/proposer/reason. Approval relates to assignment. Lease acquisition accepts IDs, then reloads/locks and validates every relation.
|
||||
- `tasks.fencing_counter` is bigint; locked atomic increment/RETURNING creates a decimal-string lease token. Lease/checkpoint composites bind exact workspace+task+assignment/session+fence.
|
||||
- `task_execution_states` durably records retry/quarantine/exhaustion.
|
||||
- Tags are normalized; legacy `tasks.tags` remains through N-1. Archive is explicit actor/reason/time and does not change lifecycle.
|
||||
- Canonical parents use RESTRICT. Events/checkpoints/artifacts/evidence are INSERT/SELECT-only for application roles. Normal flow archives/cancels; purge is audited break-glass retention work.
|
||||
|
||||
## 4. Outage proposal contract
|
||||
|
||||
`change_proposals` stores workspace, active-member proposer, source-note digest, target/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, proposal version, and submit/accepted event IDs. Both event IDs are workspace-aware composite foreign keys to `task_events(workspace_id, id)`; a bare UUID is never sufficient.
|
||||
|
||||
Submission preallocates the proposal ID. One transaction inserts `change_proposal.submitted` with the proposal workspace, `aggregate_type='change_proposal'`, `aggregate_id=<new proposal ID>`, `previous_version=NULL`, and `new_version=1`, then inserts the proposal referencing that event. Missing, foreign-workspace, wrong-type, or unrelated-proposal events abort the transaction.
|
||||
|
||||
Submit/list/get/accept/reject are explicit Gateway commands. Pending/rejected proposals are inert: no scheduling, dependency/gate satisfaction, or direct target mutation. Acceptance locks proposal+target, obtains fresh transaction-local proof, verifies pending/expected version, invokes the normal command handler, and atomically stores the emitted normal-command event ID. That event must share the proposal workspace, match `target_aggregate_type` and `target_aggregate_id`, use `causation_id=submitted_audit_event_id`, and carry `payload.changeProposalId=<locked proposal ID>`. Missing, foreign-workspace, unrelated-target, unrelated-proposal, or unrelated-command events abort acceptance.
|
||||
|
||||
## 5. Concrete current-main N-1 migration delta
|
||||
|
||||
**Inspected:** `origin/main:packages/db/src/schema.ts` at `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13). It has global teams/no workspace keys, legacy project/mission/task statuses, nullable task project/mission, `tasks.assignee/tags/due_date`, mission JSON/config, duplicated `mission_tasks.status`, legacy agent fields, and separate fleet `backlog` claims.
|
||||
|
||||
Legacy columns remain declared in unified `schema.ts` for expand + full N-1/rollback window. Generation must not infer early drops.
|
||||
|
||||
### 5.1 Ordered phases
|
||||
|
||||
1. **Pre-expand:** N-1 patch stops `mission_tasks.status` as write source; inventory writers; backup/checksum.
|
||||
2. **Expand:** add enums/tables and nullable-first columns; retain legacy declarations/uniques; emit no v1-only status.
|
||||
3. **Backfill:** bootstrap workspace; bounded idempotent cursor/checksum batches; quarantine ambiguous rows.
|
||||
4. **Validate:** no null tenant, cross-project link, ambiguous owner; status/tag/date/config retention; then constraints/NOT NULL.
|
||||
5. **Compatibility:** N-1 reads legacy; same-DB transaction mirrors only unavoidable fields; never file/Valkey dual write.
|
||||
6. **Switch:** stop N-1 writers; Gateway sole command boundary; enable canonical statuses.
|
||||
7. **Contract release:** later release after rollback/N-1; remove compatibility/global uniques/legacy fields.
|
||||
|
||||
### 5.2 New audit/proposal DDL order
|
||||
|
||||
KBN-100 migration DDL must execute in this order:
|
||||
|
||||
1. create `task_events` and its unique `(workspace_id, id)` key;
|
||||
2. create `change_proposals` with nullable acceptance-event ID and required submission-event ID;
|
||||
3. add `change_proposals_workspace_submitted_event_fk` from `(workspace_id, submitted_audit_event_id)` to `task_events(workspace_id, id)` with `ON DELETE RESTRICT`;
|
||||
4. add `change_proposals_workspace_accepted_command_event_fk` from `(workspace_id, accepted_command_audit_event_id)` to the same composite key with `ON DELETE RESTRICT`;
|
||||
5. install application-role immutability privileges and same-transaction semantic validation before enabling proposal commands.
|
||||
|
||||
The submission transaction inserts the event first using a preallocated proposal UUID, then the proposal. Acceptance inserts the normal command event before updating the locked proposal. Neither FK is omitted or replaced by a bare UUID/index check.
|
||||
|
||||
### 5.3 Field map
|
||||
|
||||
| Current | Expand/backfill | N-1 compatibility | Switch/contract |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |
|
||||
| global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later |
|
||||
| `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later |
|
||||
| project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity |
|
||||
| current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority |
|
||||
| nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later |
|
||||
| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review |
|
||||
| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority |
|
||||
| mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off |
|
||||
| mission config/metadata/phase/user | retain all; map known typed policy only | all remain declared | remove only by signed consumer inventory |
|
||||
| nullable `tasks.project_id` | derive explicit/mission project; orphan quarantine | retain nullable read/write during compatibility | canonical required; NOT NULL later |
|
||||
| `tasks.mission_id` | add project-congruent composite | old relation readable | composite authority |
|
||||
| `tasks.status` | canonical: not-started→backlog, in-progress→in_progress, others same | no ready/in_review emission | canonical authority |
|
||||
| `tasks.assignee` | deterministic active user/team/agent assignment; raw value preserved if ambiguous | mirror text only if unambiguous | canonical owner/assignment; remove after no-loss sign-off |
|
||||
| `tasks.tags` JSON | normalize trim/case/dedupe with original digest | transactionally mirror normalized rows | normalized authority; JSON later removed |
|
||||
| `tasks.due_date` | copy exactly to `due_at` | mirror | due_at authority; legacy later |
|
||||
| task common fields | preserve metadata byte-for-byte; add criteria/rank/retry/archive/version/fence | old reads valid | new fields canonical |
|
||||
| `mission_tasks.status` | keep; prohibit as write source; linked status ignored; unlinked becomes task or reject | read-only compatibility value | membership uses task mission; status dropped after no readers |
|
||||
| mission-task notes/PR/user | map to metadata/artifact/event/link/attribution; preserve | read-only | remove after parity |
|
||||
| `agents.status` | add workspace/lifecycle/runtime/roles; status remains presence | retain all legacy fields | lifecycle/roles authority; status may remain telemetry |
|
||||
| agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory |
|
||||
| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization |
|
||||
|
||||
### 5.4 Required migration tests
|
||||
|
||||
Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release.
|
||||
|
||||
Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation.
|
||||
|
||||
## 6. Ownership and Coordinator split
|
||||
|
||||
coder2 solely owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, journal/metadata, and migration tests. No other lane generates migrations. Expand is additive; no drop/rename/narrow; constraints validate before NOT NULL; compatibility is same-DB only; contract is later.
|
||||
|
||||
KBN-200/coder4 owns pure `MechanicalCoordinatorDecisionEngineV1`: complete immutable snapshots in, deterministic eligibility/proposal/retry decisions out; no ID loading, SQL, Gateway, Valkey, proof, persistence, restart I/O, or LLM.
|
||||
|
||||
KBN-210/coder3 owns `MechanicalCoordinatorServicePortV1`: ID loading, locks, fresh proof, assignment/approval persistence, atomic fencing, lease/checkpoint/outbox, Valkey wakes, durable retry/quarantine, and `recoverFromPostgres`. Cycle: load snapshots → pure decision → persist assignment → authoritative approval/policy → acquire by IDs/locks → increment fence → lease → ack/heartbeat/checkpoint → submit to review or durable retry/quarantine. No completion/certification/merge method exists.
|
||||
|
||||
## 7. Exact Gateway/DTO freeze for KBN-105
|
||||
|
||||
### 7.1 Common wire rules
|
||||
|
||||
Base is `/api/v1/workspaces/:workspaceId`. Mutations require header `Idempotency-Key` (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.
|
||||
@@ -1,261 +0,0 @@
|
||||
# Native Kanban/SOT P0–P3 — Dependency-Ordered Build Slices
|
||||
|
||||
**Status:** CANON INDEPENDENTLY APPROVED; PUBLICATION IN PROGRESS
|
||||
**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes
|
||||
**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files
|
||||
**Implementation hold:** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete.
|
||||
|
||||
> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported.
|
||||
|
||||
## Execution invariants
|
||||
|
||||
- PostgreSQL is the sole writable SOT; current-main Drizzle is the persistence foundation.
|
||||
- Mutations require fresh internal PostgreSQL transaction-local write proof and fail closed otherwise.
|
||||
- Public health DTOs, Valkey, files, browser state, providers, and outage notes cannot authorize writes.
|
||||
- Outage notes return only through attributable `change_proposals`; proposal acceptance executes the normal command.
|
||||
- Mechanical Coordinator is non-LLM and cannot invent scope, waive gates, certify, or merge.
|
||||
- Certifier is final independent gate with no merge authority.
|
||||
- Workspace is the hard tenant. Project hierarchy is project-congruent. Assignment, approval, lease, fence, checkpoint, and evidence are relationally bound.
|
||||
- Recovery tiers change recovery posture only.
|
||||
|
||||
## 1. Collision-free ownership
|
||||
|
||||
| USC lane | Exclusive ownership | Must not edit |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| **coder2 — schema/recovery** | `packages/db/src/schema.ts`; `packages/db/drizzle/**`; DB tests; `packages/config/src/recovery-posture.ts`; `packages/config/src/recovery-posture.spec.ts`; `docs/runbooks/kanban-postgres-recovery.md` | Gateway, Brain repositories, Coordinator, web, CLI/importer |
|
||||
| **coder3 — domain/Gateway/MCP server** | Kanban repositories under `packages/brain/src/`; Gateway workspace/project/mission/milestone/task/kanban/health/coord modules; **exact MCP files:** `apps/gateway/src/mcp/mcp.dto.ts`, `mcp.controller.ts`, `mcp.service.ts`, `mcp.module.ts`, `mcp.tokens.ts`, `mcp.service.spec.ts`; Gateway root wiring/tests | DB schema/migrations, `packages/coord`, web, CLI/importer |
|
||||
| **coder4 — CLI → pure Coordinator → migration tooling** | In this one fixed lane order: KBN-120 (`packages/mosaic` CLI/projection) → KBN-200 (`packages/coord/src/mechanical/**`) → KBN-300/320 (`scripts/kanban-migration/**`) | DB, Gateway/MCP server, web |
|
||||
| **coder5 — web** | `apps/web/src/app/(dashboard)/{tasks,projects}/**`; `apps/web/src/components/{tasks,projects}/**`; Kanban web API/types; later Coordinator/migration-review routes | DB, Gateway, Coordinator, CLI/importer |
|
||||
| **Mos — publication/integration** | Contract amendments, exact endpoint registry publication, serialized root exports/manifests/lockfiles, integration gates | Active lane feature files |
|
||||
|
||||
Shared roots, package exports/manifests, lockfiles, and generated artifacts are integration-serialized. Contract changes stop affected lanes and require Mos approval.
|
||||
|
||||
## 2. Parallelization legend
|
||||
|
||||
- **SERIAL:** prerequisite must be complete and reviewed.
|
||||
- **PARALLEL-GROUP:** disjoint files and exact frozen contract permit concurrent work.
|
||||
- **LANE-SERIAL:** one lane's stated order cannot change.
|
||||
- **INTEGRATION-SERIAL:** component heads green first; semantic findings return to owner.
|
||||
|
||||
## 3. Corrected dependency graph
|
||||
|
||||
```text
|
||||
KBN-000 canon remediation
|
||||
-> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE)
|
||||
-> KBN-100 schema + concrete N-1 migration implementation
|
||||
├─ KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL)
|
||||
│ ├─ KBN-110 domain + Gateway + MCP server implementation
|
||||
│ ├─ KBN-120 CLI/projection implementation [coder4 first]
|
||||
│ └─ KBN-130 web MVP implementation
|
||||
└─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
|
||||
KBN-110 + KBN-120 + KBN-130 + KBN-115
|
||||
-> KBN-140 P1 integration/SIT
|
||||
-> KBN-200 pure decision engine [coder4 after KBN-120]
|
||||
-> KBN-210 persistence/service adapter + approval/lease binding
|
||||
-> KBN-220 Coordinator operations UI
|
||||
-> KBN-230 P2 concurrency/fault/gate integration
|
||||
KBN-230
|
||||
-> KBN-300 importer dry-run/apply/verify [coder4 after KBN-200]
|
||||
├─ KBN-310 migration reviewer UI
|
||||
└─ KBN-320 cutover/rollback tooling [coder4 after KBN-300]
|
||||
KBN-310 + KBN-320
|
||||
-> KBN-330 rehearsal/reconciliation
|
||||
-> KBN-340 owner-gated cutover/stabilization
|
||||
```
|
||||
|
||||
No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320.
|
||||
|
||||
## 4. P0 — Canon, threat gate, schema, and exact API freeze
|
||||
|
||||
### KBN-000 — Remediate and publish canon
|
||||
|
||||
- **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.
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* Mosaic Native Kanban — frozen health/error contract v1.
|
||||
* Publication contract only; no runtime implementation is included here.
|
||||
*
|
||||
* PostgreSQL is the sole writable SOT. Public health DTOs are observations,
|
||||
* never write authority. Only an internal transaction-local proof produced by
|
||||
* the PostgreSQL adapter may authorize a mutation.
|
||||
*/
|
||||
|
||||
export const KANBAN_CONTRACT_VERSION = '1.0.0' as const;
|
||||
|
||||
export const kanbanHealthStates = ['healthy', 'read-only-degraded', 'write-unavailable'] as const;
|
||||
export type KanbanHealthState = (typeof kanbanHealthStates)[number];
|
||||
|
||||
interface KanbanHealthBaseV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
checkedAt: string;
|
||||
/** Observation expires at this RFC 3339 instant; it still never authorizes writes. */
|
||||
validUntil: string;
|
||||
policyRevision: string;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface HealthyKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
|
||||
state: 'healthy';
|
||||
readHealthProven: true;
|
||||
writeHealthProven: true;
|
||||
}
|
||||
|
||||
export interface ReadOnlyDegradedKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
|
||||
state: 'read-only-degraded';
|
||||
readHealthProven: true;
|
||||
writeHealthProven: false;
|
||||
}
|
||||
|
||||
export interface WriteUnavailableKanbanHealthResponseV1 extends KanbanHealthBaseV1 {
|
||||
state: 'write-unavailable';
|
||||
readHealthProven: false;
|
||||
writeHealthProven: false;
|
||||
}
|
||||
|
||||
/** Public, discriminated observation. Contradictory combinations are unrepresentable. */
|
||||
export type KanbanHealthResponseV1 =
|
||||
| HealthyKanbanHealthResponseV1
|
||||
| ReadOnlyDegradedKanbanHealthResponseV1
|
||||
| WriteUnavailableKanbanHealthResponseV1;
|
||||
|
||||
/** Pure evaluation context. It cannot authorize a mutation. */
|
||||
export interface KanbanEvaluationContextV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
workspaceId: string;
|
||||
correlationId: string;
|
||||
now: string;
|
||||
policyRevision: string;
|
||||
observedHealth: KanbanHealthResponseV1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-exported brand: public DTO deserialization cannot construct this type.
|
||||
* The PostgreSQL adapter mints it only after a fresh write probe inside the same
|
||||
* transaction and validates checkedAt <= now < validUntil and policy revision.
|
||||
*/
|
||||
declare const postgresWriteHealthProofBrand: unique symbol;
|
||||
export interface PostgresWriteHealthProofV1 {
|
||||
readonly [postgresWriteHealthProofBrand]: true;
|
||||
readonly source: 'postgres-transaction-local-write-probe';
|
||||
readonly transactionId: string;
|
||||
readonly checkedAt: string;
|
||||
readonly validUntil: string;
|
||||
readonly policyRevision: string;
|
||||
}
|
||||
|
||||
/** Internal mutation context; MUST NOT appear in REST/MCP/CLI request DTOs. */
|
||||
export interface InternalKanbanMutationContextV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
workspaceId: string;
|
||||
correlationId: string;
|
||||
causationId?: string;
|
||||
idempotencyKey: string;
|
||||
now: string;
|
||||
expectedPolicyRevision: string;
|
||||
writeProof: PostgresWriteHealthProofV1;
|
||||
}
|
||||
|
||||
interface MutationFailureBaseV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
retryable: false;
|
||||
requestOutcome: 'not_applied';
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** KCR-016: code/state pairing is exact and cannot cross-map. */
|
||||
export interface ReadOnlyWriteHealthDenialV1 extends MutationFailureBaseV1 {
|
||||
kind: 'deliberate_fail_closed_denial';
|
||||
code: 'KANBAN_WRITE_HEALTH_UNPROVEN';
|
||||
healthState: 'read-only-degraded';
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface WriteUnavailableDenialV1 extends MutationFailureBaseV1 {
|
||||
kind: 'deliberate_fail_closed_denial';
|
||||
code: 'KANBAN_WRITE_UNAVAILABLE';
|
||||
healthState: 'write-unavailable';
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export type DeliberateWriteDenialV1 = ReadOnlyWriteHealthDenialV1 | WriteUnavailableDenialV1;
|
||||
|
||||
export const transportErrorCodes = [
|
||||
'GATEWAY_UNREACHABLE',
|
||||
'GATEWAY_TIMEOUT',
|
||||
'UPSTREAM_BAD_GATEWAY',
|
||||
] as const;
|
||||
export type TransportErrorCode = (typeof transportErrorCodes)[number];
|
||||
|
||||
/** Client-normalized transport uncertainty; never an authoritative 503 body. */
|
||||
export interface RetryableTransportErrorV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
kind: 'retryable_transport_error';
|
||||
code: TransportErrorCode;
|
||||
retryable: true;
|
||||
requestOutcome: 'unknown';
|
||||
/** Retry MUST reuse this exact key. */
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface VersionConflictV1 {
|
||||
contractVersion: typeof KANBAN_CONTRACT_VERSION;
|
||||
kind: 'version_conflict';
|
||||
code: 'AGGREGATE_VERSION_CONFLICT';
|
||||
retryable: false;
|
||||
requestOutcome: 'not_applied';
|
||||
aggregateType: 'project' | 'mission' | 'milestone' | 'task' | 'change_proposal';
|
||||
aggregateId: string;
|
||||
expectedVersion: number;
|
||||
actualVersion: number;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type KanbanMutationFailureV1 =
|
||||
| DeliberateWriteDenialV1
|
||||
| RetryableTransportErrorV1
|
||||
| VersionConflictV1;
|
||||
|
||||
export const kanbanHealthCapabilities: Readonly<
|
||||
Record<KanbanHealthState, { canonicalReads: boolean; mutations: boolean }>
|
||||
> = {
|
||||
healthy: { canonicalReads: true, mutations: true },
|
||||
'read-only-degraded': { canonicalReads: true, mutations: false },
|
||||
'write-unavailable': { canonicalReads: false, mutations: false },
|
||||
};
|
||||
|
||||
/** Exact HTTP/error normalization freeze; 503, transport, and 409 cannot cross-map. */
|
||||
export const kanbanFailureHttpMapV1 = {
|
||||
KANBAN_WRITE_HEALTH_UNPROVEN: {
|
||||
httpStatus: 503,
|
||||
kind: 'deliberate_fail_closed_denial',
|
||||
requestOutcome: 'not_applied',
|
||||
retryable: false,
|
||||
},
|
||||
KANBAN_WRITE_UNAVAILABLE: {
|
||||
httpStatus: 503,
|
||||
kind: 'deliberate_fail_closed_denial',
|
||||
requestOutcome: 'not_applied',
|
||||
retryable: false,
|
||||
},
|
||||
AGGREGATE_VERSION_CONFLICT: {
|
||||
httpStatus: 409,
|
||||
kind: 'version_conflict',
|
||||
requestOutcome: 'not_applied',
|
||||
retryable: false,
|
||||
},
|
||||
GATEWAY_UNREACHABLE: {
|
||||
httpStatus: 502,
|
||||
kind: 'retryable_transport_error',
|
||||
requestOutcome: 'unknown',
|
||||
retryable: true,
|
||||
},
|
||||
GATEWAY_TIMEOUT: {
|
||||
httpStatus: 504,
|
||||
kind: 'retryable_transport_error',
|
||||
requestOutcome: 'unknown',
|
||||
retryable: true,
|
||||
},
|
||||
UPSTREAM_BAD_GATEWAY: {
|
||||
httpStatus: 502,
|
||||
kind: 'retryable_transport_error',
|
||||
requestOutcome: 'unknown',
|
||||
retryable: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Required negative contract tests:
|
||||
* - contradictory state/proof booleans fail type/schema validation;
|
||||
* - expired internal proof and policy mismatch deny before mutation;
|
||||
* - Valkey-only liveness cannot mint PostgresWriteHealthProofV1;
|
||||
* - public/caller-forged `healthy` cannot enter InternalKanbanMutationContextV1;
|
||||
* - authoritative 503, transport 502/504/timeout, and 409 mappings are exhaustive.
|
||||
*/
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,419 +0,0 @@
|
||||
/**
|
||||
* Mosaic Native Kanban — frozen Mechanical Coordinator contracts v1.
|
||||
*
|
||||
* The pure decision engine and persistence/orchestration service are separate.
|
||||
* Neither surface can create scope, edit acceptance, waive gates, certify,
|
||||
* merge, release a deployment, or close a provider issue.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DeliberateWriteDenialV1,
|
||||
InternalKanbanMutationContextV1,
|
||||
KanbanEvaluationContextV1,
|
||||
KanbanMutationFailureV1,
|
||||
RetryableTransportErrorV1,
|
||||
VersionConflictV1,
|
||||
} from './health-state.v1.js';
|
||||
|
||||
export const COORDINATOR_CONTRACT_VERSION = '1.0.0' as const;
|
||||
export type Uuid = string;
|
||||
export type IsoTimestamp = string;
|
||||
/** PostgreSQL bigint-safe decimal string; never a JavaScript number. */
|
||||
export type FencingTokenV1 = string;
|
||||
|
||||
export const specialistRoles = [
|
||||
'planning',
|
||||
'enhance',
|
||||
'coder',
|
||||
'review',
|
||||
'security-review',
|
||||
'pr-monitor',
|
||||
'certifier',
|
||||
] as const;
|
||||
export type SpecialistRole = (typeof specialistRoles)[number];
|
||||
|
||||
/** One vocabulary shared with task_assignment_state_v1 in the Drizzle schema. */
|
||||
export const assignmentStates = [
|
||||
'awaiting_approval',
|
||||
'policy_pre_authorized',
|
||||
'approved',
|
||||
'rejected',
|
||||
'leased',
|
||||
'released',
|
||||
'expired',
|
||||
'superseded',
|
||||
] as const;
|
||||
export type AssignmentStateV1 = (typeof assignmentStates)[number];
|
||||
|
||||
export const readinessStates = [
|
||||
'dependency-gated',
|
||||
'schedule-gated',
|
||||
'policy-gated',
|
||||
'lease-available',
|
||||
'leased',
|
||||
'retry-delayed',
|
||||
'exhausted',
|
||||
'quarantined',
|
||||
] as const;
|
||||
export type ReadinessState = (typeof readinessStates)[number];
|
||||
|
||||
export interface RetryStateSnapshotV1 {
|
||||
disposition: 'available' | 'retry_delayed' | 'quarantined' | 'exhausted';
|
||||
attemptCount: number;
|
||||
maxAttempts: number;
|
||||
nextEligibleAt: IsoTimestamp | null;
|
||||
idempotent: boolean;
|
||||
terminalReason: string | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface TaskEligibilitySnapshotV1 {
|
||||
workspaceId: Uuid;
|
||||
taskId: Uuid;
|
||||
taskVersion: number;
|
||||
projectId: Uuid;
|
||||
projectActive: boolean;
|
||||
missionId: Uuid | null;
|
||||
missionActive: boolean;
|
||||
status: 'ready';
|
||||
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||
boardRank: string;
|
||||
dueAt: IsoTimestamp | null;
|
||||
notBeforeAt: IsoTimestamp | null;
|
||||
createdAt: IsoTimestamp;
|
||||
requiredRole: SpecialistRole;
|
||||
requiredCapabilities: readonly string[];
|
||||
blockingDependencies: readonly {
|
||||
taskId: Uuid;
|
||||
done: boolean;
|
||||
completionConditionSatisfied: boolean;
|
||||
}[];
|
||||
releaseApproval: {
|
||||
decisionId: Uuid;
|
||||
approved: boolean;
|
||||
policyRevision: string;
|
||||
} | null;
|
||||
activeLeaseId: Uuid | null;
|
||||
retry: RetryStateSnapshotV1;
|
||||
}
|
||||
|
||||
export interface AgentSessionSnapshotV1 {
|
||||
workspaceId: Uuid;
|
||||
agentId: Uuid;
|
||||
sessionId: Uuid;
|
||||
state: 'available' | 'busy';
|
||||
roles: readonly SpecialistRole[];
|
||||
capabilities: readonly string[];
|
||||
capacity: number;
|
||||
activeLeaseCount: number;
|
||||
heartbeatAt: IsoTimestamp;
|
||||
}
|
||||
|
||||
export interface EligibilityExplanationV1 {
|
||||
taskId: Uuid;
|
||||
eligible: boolean;
|
||||
readiness: ReadinessState;
|
||||
reasons: readonly {
|
||||
gate:
|
||||
| 'status'
|
||||
| 'project'
|
||||
| 'mission'
|
||||
| 'dependency'
|
||||
| 'schedule'
|
||||
| 'retry'
|
||||
| 'approval'
|
||||
| 'lease'
|
||||
| 'capability'
|
||||
| 'capacity'
|
||||
| 'health';
|
||||
satisfied: boolean;
|
||||
code: string;
|
||||
detail: string;
|
||||
}[];
|
||||
policyRevision: string;
|
||||
evaluatedAt: IsoTimestamp;
|
||||
}
|
||||
|
||||
export interface AssignmentProposalDecisionV1 {
|
||||
workspaceId: Uuid;
|
||||
taskId: Uuid;
|
||||
taskVersion: number;
|
||||
targetAgentId: Uuid;
|
||||
targetSessionId: Uuid;
|
||||
specialistRole: SpecialistRole;
|
||||
initialState: 'awaiting_approval' | 'policy_pre_authorized';
|
||||
policyRevision: string;
|
||||
explanation: EligibilityExplanationV1;
|
||||
expiresAt: IsoTimestamp;
|
||||
}
|
||||
|
||||
export interface AssignmentCycleSnapshotV1 {
|
||||
context: KanbanEvaluationContextV1;
|
||||
tasks: readonly TaskEligibilitySnapshotV1[];
|
||||
sessions: readonly AgentSessionSnapshotV1[];
|
||||
workspaceFairness: Readonly<Record<Uuid, number>>;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface AssignmentCycleDecisionV1 {
|
||||
evaluatedTaskCount: number;
|
||||
proposals: readonly AssignmentProposalDecisionV1[];
|
||||
explanations: readonly EligibilityExplanationV1[];
|
||||
}
|
||||
|
||||
export interface LeaseExpirySnapshotV1 {
|
||||
workspaceId: Uuid;
|
||||
taskId: Uuid;
|
||||
taskVersion: number;
|
||||
leaseId: Uuid;
|
||||
assignmentId: Uuid;
|
||||
sessionId: Uuid;
|
||||
fencingToken: FencingTokenV1;
|
||||
state: 'pending_ack' | 'active';
|
||||
acknowledgeBy: IsoTimestamp;
|
||||
expiresAt: IsoTimestamp;
|
||||
lastHeartbeatAt: IsoTimestamp | null;
|
||||
retry: RetryStateSnapshotV1;
|
||||
}
|
||||
|
||||
export interface LeaseExpiryDecisionV1 {
|
||||
leaseId: Uuid;
|
||||
action: 'retain' | 'release' | 'retry' | 'quarantine' | 'exhaust';
|
||||
reason: string;
|
||||
nextEligibleAt: IsoTimestamp | null;
|
||||
}
|
||||
|
||||
/** Pure package owned by KBN-200. It receives complete immutable snapshots. */
|
||||
export interface MechanicalCoordinatorDecisionEngineV1 {
|
||||
evaluateAssignmentCycle(snapshot: AssignmentCycleSnapshotV1): AssignmentCycleDecisionV1;
|
||||
explainEligibility(
|
||||
context: KanbanEvaluationContextV1,
|
||||
task: TaskEligibilitySnapshotV1,
|
||||
sessions: readonly AgentSessionSnapshotV1[],
|
||||
): EligibilityExplanationV1;
|
||||
decideLeaseExpiry(
|
||||
context: KanbanEvaluationContextV1,
|
||||
lease: LeaseExpirySnapshotV1,
|
||||
): LeaseExpiryDecisionV1;
|
||||
}
|
||||
|
||||
export interface PersistedAssignmentV1 {
|
||||
assignmentId: Uuid;
|
||||
workspaceId: Uuid;
|
||||
taskId: Uuid;
|
||||
taskVersion: number;
|
||||
targetAgentId: Uuid;
|
||||
targetSessionId: Uuid;
|
||||
specialistRole: SpecialistRole;
|
||||
state: AssignmentStateV1;
|
||||
policyRevision: string;
|
||||
proposedBy: { kind: 'user' | 'agent'; id: Uuid };
|
||||
reason: string;
|
||||
createdAt: IsoTimestamp;
|
||||
expiresAt: IsoTimestamp;
|
||||
}
|
||||
|
||||
export interface TaskLeaseV1 {
|
||||
leaseId: Uuid;
|
||||
workspaceId: Uuid;
|
||||
taskId: Uuid;
|
||||
taskVersion: number;
|
||||
assignmentId: Uuid;
|
||||
agentId: Uuid;
|
||||
sessionId: Uuid;
|
||||
state: 'pending_ack' | 'active';
|
||||
fencingToken: FencingTokenV1;
|
||||
attempt: number;
|
||||
acquiredAt: IsoTimestamp;
|
||||
acknowledgeBy: IsoTimestamp;
|
||||
lastHeartbeatAt: IsoTimestamp | null;
|
||||
expiresAt: IsoTimestamp;
|
||||
}
|
||||
|
||||
interface ServiceCommandBaseV1 {
|
||||
context: InternalKanbanMutationContextV1;
|
||||
taskId: Uuid;
|
||||
expectedTaskVersion: number;
|
||||
}
|
||||
|
||||
export interface AcquireApprovedLeaseCommandV1 extends ServiceCommandBaseV1 {
|
||||
assignmentId: Uuid;
|
||||
approvalDecisionId: Uuid;
|
||||
targetSessionId: Uuid;
|
||||
leaseTtlSeconds: number;
|
||||
}
|
||||
|
||||
export interface LeaseCommandV1 extends ServiceCommandBaseV1 {
|
||||
leaseId: Uuid;
|
||||
sessionId: Uuid;
|
||||
fencingToken: FencingTokenV1;
|
||||
}
|
||||
|
||||
export interface HeartbeatLeaseCommandV1 extends LeaseCommandV1 {
|
||||
extendSeconds: number;
|
||||
}
|
||||
|
||||
export interface CheckpointCommandV1 extends LeaseCommandV1 {
|
||||
sequence: number;
|
||||
resumableSummary: string;
|
||||
artifactIds: readonly Uuid[];
|
||||
contextUsagePercent: number;
|
||||
}
|
||||
|
||||
export interface SubmitForReviewCommandV1 extends LeaseCommandV1 {
|
||||
artifactIds: readonly Uuid[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ReleaseLeaseCommandV1 extends LeaseCommandV1 {
|
||||
reason:
|
||||
| 'worker_requested'
|
||||
| 'ack_timeout'
|
||||
| 'heartbeat_timeout'
|
||||
| 'task_submitted'
|
||||
| 'policy_revoked'
|
||||
| 'shutdown';
|
||||
}
|
||||
|
||||
export interface AssignmentCycleCommandV1 {
|
||||
context: InternalKanbanMutationContextV1;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ExpirySweepCommandV1 {
|
||||
context: InternalKanbanMutationContextV1;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface RecoverCoordinatorCommandV1 {
|
||||
context: InternalKanbanMutationContextV1;
|
||||
}
|
||||
|
||||
interface CoordinatorRejectionBaseV1 {
|
||||
kind: 'coordinator_rejection';
|
||||
retryable: false;
|
||||
requestOutcome: 'not_applied';
|
||||
correlationId: Uuid;
|
||||
idempotencyKey: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type CoordinatorPolicyRejectionV1 =
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'WORKSPACE_MISMATCH' })
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'TASK_NOT_ELIGIBLE';
|
||||
explanation: EligibilityExplanationV1;
|
||||
})
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_REQUIRED' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_STALE' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_STALE' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_TARGET_MISMATCH' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'POLICY_REVISION_MISMATCH' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'ARTIFACT_WORKSPACE_MISMATCH' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_ALREADY_ACTIVE' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_FOUND' })
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_ACTIVE' })
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'ACK_DEADLINE_EXPIRED';
|
||||
expiredAt: IsoTimestamp;
|
||||
})
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'FENCING_TOKEN_STALE';
|
||||
currentFencingToken: FencingTokenV1;
|
||||
})
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'SESSION_MISMATCH' })
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'HEARTBEAT_EXPIRED';
|
||||
expiredAt: IsoTimestamp;
|
||||
})
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'CHECKPOINT_SEQUENCE_CONFLICT';
|
||||
currentSequence: number;
|
||||
})
|
||||
| (CoordinatorRejectionBaseV1 & { code: 'RETRY_EXHAUSTED' })
|
||||
| (CoordinatorRejectionBaseV1 & {
|
||||
code: 'NON_IDEMPOTENT_RETRY_REQUIRES_ORCHESTRATOR';
|
||||
});
|
||||
|
||||
/** Explicit mapping to the Gateway mutation failure union; no arbitrary booleans. */
|
||||
export type CoordinatorFailureV1 =
|
||||
| DeliberateWriteDenialV1
|
||||
| VersionConflictV1
|
||||
| RetryableTransportErrorV1
|
||||
| CoordinatorPolicyRejectionV1;
|
||||
|
||||
export interface CoordinatorSuccessV1<T> {
|
||||
ok: true;
|
||||
value: T;
|
||||
correlationId: Uuid;
|
||||
}
|
||||
export interface CoordinatorFailureResultV1 {
|
||||
ok: false;
|
||||
failure: CoordinatorFailureV1;
|
||||
}
|
||||
export type CoordinatorResultV1<T> = CoordinatorSuccessV1<T> | CoordinatorFailureResultV1;
|
||||
|
||||
export interface ExpirySweepResultV1 {
|
||||
examined: number;
|
||||
released: readonly Uuid[];
|
||||
retryScheduled: readonly Uuid[];
|
||||
quarantined: readonly Uuid[];
|
||||
exhausted: readonly Uuid[];
|
||||
}
|
||||
|
||||
export interface RestartRecoveryResultV1 {
|
||||
activeLeaseIds: readonly Uuid[];
|
||||
expiredLeaseIds: readonly Uuid[];
|
||||
pendingAssignmentIds: readonly Uuid[];
|
||||
pendingOutboxEventIds: readonly Uuid[];
|
||||
}
|
||||
|
||||
/** Persistence/Gateway adapter owned by KBN-210. */
|
||||
export interface MechanicalCoordinatorServicePortV1 {
|
||||
/** Loads immutable snapshots, invokes pure engine, and persists proposals atomically. */
|
||||
runAssignmentCycle(
|
||||
command: AssignmentCycleCommandV1,
|
||||
): Promise<CoordinatorResultV1<{ assignments: readonly PersistedAssignmentV1[] }>>;
|
||||
|
||||
/** Query path loads by ID; public health observation cannot authorize mutation. */
|
||||
getEligibilityExplanation(
|
||||
context: KanbanEvaluationContextV1,
|
||||
taskId: Uuid,
|
||||
): Promise<CoordinatorResultV1<EligibilityExplanationV1>>;
|
||||
|
||||
/**
|
||||
* Accepts IDs only. Implementation reloads and locks assignment + approval +
|
||||
* task + target session in PostgreSQL, then verifies workspace, task version,
|
||||
* target agent/session, state, expiry, policy revision, and current approval.
|
||||
*/
|
||||
acquireApprovedLease(
|
||||
command: AcquireApprovedLeaseCommandV1,
|
||||
): Promise<CoordinatorResultV1<TaskLeaseV1>>;
|
||||
|
||||
acknowledgeLease(command: LeaseCommandV1): Promise<CoordinatorResultV1<TaskLeaseV1>>;
|
||||
heartbeatLease(command: HeartbeatLeaseCommandV1): Promise<CoordinatorResultV1<TaskLeaseV1>>;
|
||||
appendCheckpoint(
|
||||
command: CheckpointCommandV1,
|
||||
): Promise<CoordinatorResultV1<{ checkpointId: Uuid }>>;
|
||||
submitForReview(
|
||||
command: SubmitForReviewCommandV1,
|
||||
): Promise<CoordinatorResultV1<{ taskVersion: number; status: 'in_review' }>>;
|
||||
releaseLease(command: ReleaseLeaseCommandV1): Promise<CoordinatorResultV1<{ released: true }>>;
|
||||
expireAndRecover(
|
||||
command: ExpirySweepCommandV1,
|
||||
): Promise<CoordinatorResultV1<ExpirySweepResultV1>>;
|
||||
recoverFromPostgres(
|
||||
command: RecoverCoordinatorCommandV1,
|
||||
): Promise<CoordinatorResultV1<RestartRecoveryResultV1>>;
|
||||
}
|
||||
|
||||
/** Compile-time mapping guarantee: Coordinator Gateway failures are Kanban failures or exact policy rejections. */
|
||||
export function isKanbanMutationFailureV1(
|
||||
failure: CoordinatorFailureV1,
|
||||
): failure is KanbanMutationFailureV1 {
|
||||
return (
|
||||
failure.kind === 'deliberate_fail_closed_denial' ||
|
||||
failure.kind === 'retryable_transport_error' ||
|
||||
failure.kind === 'version_conflict'
|
||||
);
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
/**
|
||||
* Mosaic Native Kanban — frozen recovery-posture contract v1.
|
||||
* Recovery posture is configurable; SOT, write-health, Coordinator authority,
|
||||
* and gate semantics are not fields and cannot be overridden.
|
||||
*/
|
||||
|
||||
export const RECOVERY_POSTURE_CONTRACT_VERSION = '1.0.0' as const;
|
||||
export const recoveryTiers = ['lite', 'standard', 'high-assurance'] as const;
|
||||
export type RecoveryTier = (typeof recoveryTiers)[number];
|
||||
|
||||
export interface OffClusterStorageV1 {
|
||||
required: true;
|
||||
encrypted: true;
|
||||
separateFailureDomain: true;
|
||||
minimumCopies: number;
|
||||
storageClass: 'encrypted-object-storage' | 'encrypted-backup-target';
|
||||
}
|
||||
|
||||
export interface RecoveryPostureV1 {
|
||||
contractVersion: typeof RECOVERY_POSTURE_CONTRACT_VERSION;
|
||||
tier: RecoveryTier;
|
||||
targetRpoMinutes: number;
|
||||
targetRtoMinutes: number;
|
||||
baseBackupIntervalHours: number;
|
||||
/** null means WAL archival/PITR is disabled. */
|
||||
walArchiveIntervalMinutes: number | null;
|
||||
/** 0 means PITR is disabled. */
|
||||
pitrRetentionDays: number;
|
||||
restoreTestIntervalDays: number;
|
||||
breakGlassDrillIntervalDays: number;
|
||||
offClusterStorage: OffClusterStorageV1;
|
||||
}
|
||||
|
||||
export const recoveryPostureDefaults: Readonly<Record<RecoveryTier, RecoveryPostureV1>> = {
|
||||
lite: {
|
||||
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
||||
tier: 'lite',
|
||||
targetRpoMinutes: 24 * 60,
|
||||
targetRtoMinutes: 24 * 60,
|
||||
baseBackupIntervalHours: 24,
|
||||
walArchiveIntervalMinutes: null,
|
||||
pitrRetentionDays: 0,
|
||||
restoreTestIntervalDays: 90,
|
||||
breakGlassDrillIntervalDays: 365,
|
||||
offClusterStorage: {
|
||||
required: true,
|
||||
encrypted: true,
|
||||
separateFailureDomain: true,
|
||||
minimumCopies: 1,
|
||||
storageClass: 'encrypted-backup-target',
|
||||
},
|
||||
},
|
||||
standard: {
|
||||
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
||||
tier: 'standard',
|
||||
targetRpoMinutes: 60,
|
||||
targetRtoMinutes: 8 * 60,
|
||||
baseBackupIntervalHours: 24,
|
||||
walArchiveIntervalMinutes: 15,
|
||||
pitrRetentionDays: 14,
|
||||
restoreTestIntervalDays: 90,
|
||||
breakGlassDrillIntervalDays: 180,
|
||||
offClusterStorage: {
|
||||
required: true,
|
||||
encrypted: true,
|
||||
separateFailureDomain: true,
|
||||
minimumCopies: 1,
|
||||
storageClass: 'encrypted-object-storage',
|
||||
},
|
||||
},
|
||||
'high-assurance': {
|
||||
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
||||
tier: 'high-assurance',
|
||||
targetRpoMinutes: 15,
|
||||
targetRtoMinutes: 4 * 60,
|
||||
baseBackupIntervalHours: 24,
|
||||
walArchiveIntervalMinutes: 5,
|
||||
pitrRetentionDays: 35,
|
||||
restoreTestIntervalDays: 30,
|
||||
breakGlassDrillIntervalDays: 90,
|
||||
offClusterStorage: {
|
||||
required: true,
|
||||
encrypted: true,
|
||||
separateFailureDomain: true,
|
||||
minimumCopies: 1,
|
||||
storageClass: 'encrypted-object-storage',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Shape schema. Normative cross-field semantics are enforced by validateRecoveryPostureV1. */
|
||||
export const recoveryPostureJsonSchemaV1 = {
|
||||
$id: 'https://mosaicstack.dev/contracts/recovery-posture.v1.schema.json',
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
'contractVersion',
|
||||
'tier',
|
||||
'targetRpoMinutes',
|
||||
'targetRtoMinutes',
|
||||
'baseBackupIntervalHours',
|
||||
'walArchiveIntervalMinutes',
|
||||
'pitrRetentionDays',
|
||||
'restoreTestIntervalDays',
|
||||
'breakGlassDrillIntervalDays',
|
||||
'offClusterStorage',
|
||||
],
|
||||
properties: {
|
||||
contractVersion: { const: RECOVERY_POSTURE_CONTRACT_VERSION },
|
||||
tier: { enum: recoveryTiers },
|
||||
targetRpoMinutes: { type: 'integer', minimum: 1 },
|
||||
targetRtoMinutes: { type: 'integer', minimum: 1 },
|
||||
baseBackupIntervalHours: { type: 'integer', minimum: 1 },
|
||||
walArchiveIntervalMinutes: {
|
||||
anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }],
|
||||
},
|
||||
pitrRetentionDays: { type: 'integer', minimum: 0 },
|
||||
restoreTestIntervalDays: { type: 'integer', minimum: 1 },
|
||||
breakGlassDrillIntervalDays: { type: 'integer', minimum: 1 },
|
||||
offClusterStorage: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['required', 'encrypted', 'separateFailureDomain', 'minimumCopies', 'storageClass'],
|
||||
properties: {
|
||||
required: { const: true },
|
||||
encrypted: { const: true },
|
||||
separateFailureDomain: { const: true },
|
||||
minimumCopies: { type: 'integer', minimum: 1 },
|
||||
storageClass: {
|
||||
enum: ['encrypted-object-storage', 'encrypted-backup-target'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const recoveryValidationCodes = [
|
||||
'INVALID_SHAPE',
|
||||
'UNKNOWN_FIELD',
|
||||
'PITR_REQUIRES_WAL',
|
||||
'WAL_REQUIRES_PITR',
|
||||
'RPO_BETTER_THAN_MECHANISM',
|
||||
'OFF_CLUSTER_REQUIRED',
|
||||
'HIGH_ASSURANCE_WEAKENED',
|
||||
] as const;
|
||||
export type RecoveryValidationCode = (typeof recoveryValidationCodes)[number];
|
||||
|
||||
export interface RecoveryValidationIssueV1 {
|
||||
code: RecoveryValidationCode;
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
export type RecoveryValidationResultV1 =
|
||||
| { ok: true; value: RecoveryPostureV1 }
|
||||
| { ok: false; issues: RecoveryValidationIssueV1[] };
|
||||
|
||||
const topLevelFields = new Set([
|
||||
'contractVersion',
|
||||
'tier',
|
||||
'targetRpoMinutes',
|
||||
'targetRtoMinutes',
|
||||
'baseBackupIntervalHours',
|
||||
'walArchiveIntervalMinutes',
|
||||
'pitrRetentionDays',
|
||||
'restoreTestIntervalDays',
|
||||
'breakGlassDrillIntervalDays',
|
||||
'offClusterStorage',
|
||||
]);
|
||||
const storageFields = new Set([
|
||||
'required',
|
||||
'encrypted',
|
||||
'separateFailureDomain',
|
||||
'minimumCopies',
|
||||
'storageClass',
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && Number(value) > 0;
|
||||
}
|
||||
function isNonnegativeInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normative parser/refinement. Deployment code MUST call this function (or a
|
||||
* byte-for-byte behaviorally equivalent generated validator), not JSON Schema
|
||||
* shape validation alone.
|
||||
*/
|
||||
export function validateRecoveryPostureV1(input: unknown): RecoveryValidationResultV1 {
|
||||
const issues: RecoveryValidationIssueV1[] = [];
|
||||
if (!isRecord(input)) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: [{ code: 'INVALID_SHAPE', path: '$', message: 'posture must be an object' }],
|
||||
};
|
||||
}
|
||||
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!topLevelFields.has(key)) {
|
||||
issues.push({ code: 'UNKNOWN_FIELD', path: `$.${key}`, message: 'unknown field' });
|
||||
}
|
||||
}
|
||||
|
||||
const tier = input['tier'];
|
||||
const storage = input['offClusterStorage'];
|
||||
const integerFields = [
|
||||
'targetRpoMinutes',
|
||||
'targetRtoMinutes',
|
||||
'baseBackupIntervalHours',
|
||||
'restoreTestIntervalDays',
|
||||
'breakGlassDrillIntervalDays',
|
||||
] as const;
|
||||
|
||||
if (input['contractVersion'] !== RECOVERY_POSTURE_CONTRACT_VERSION) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.contractVersion',
|
||||
message: `must equal ${RECOVERY_POSTURE_CONTRACT_VERSION}`,
|
||||
});
|
||||
}
|
||||
if (!recoveryTiers.includes(tier as RecoveryTier)) {
|
||||
issues.push({ code: 'INVALID_SHAPE', path: '$.tier', message: 'unknown recovery tier' });
|
||||
}
|
||||
for (const field of integerFields) {
|
||||
if (!isPositiveInteger(input[field])) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: `$.${field}`,
|
||||
message: 'must be a positive integer',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!isNonnegativeInteger(input['pitrRetentionDays'])) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.pitrRetentionDays',
|
||||
message: 'must be a nonnegative integer',
|
||||
});
|
||||
}
|
||||
if (
|
||||
input['walArchiveIntervalMinutes'] !== null &&
|
||||
!isPositiveInteger(input['walArchiveIntervalMinutes'])
|
||||
) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.walArchiveIntervalMinutes',
|
||||
message: 'must be null or a positive integer',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isRecord(storage)) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.offClusterStorage',
|
||||
message: 'must be an object',
|
||||
});
|
||||
} else {
|
||||
for (const key of Object.keys(storage)) {
|
||||
if (!storageFields.has(key)) {
|
||||
issues.push({
|
||||
code: 'UNKNOWN_FIELD',
|
||||
path: `$.offClusterStorage.${key}`,
|
||||
message: 'unknown field',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
storage['required'] !== true ||
|
||||
storage['encrypted'] !== true ||
|
||||
storage['separateFailureDomain'] !== true
|
||||
) {
|
||||
issues.push({
|
||||
code: 'OFF_CLUSTER_REQUIRED',
|
||||
path: '$.offClusterStorage',
|
||||
message: 'storage must be required, encrypted, and in a separate failure domain',
|
||||
});
|
||||
}
|
||||
if (!isPositiveInteger(storage['minimumCopies'])) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.offClusterStorage.minimumCopies',
|
||||
message: 'must be a positive integer',
|
||||
});
|
||||
}
|
||||
if (
|
||||
storage['storageClass'] !== 'encrypted-object-storage' &&
|
||||
storage['storageClass'] !== 'encrypted-backup-target'
|
||||
) {
|
||||
issues.push({
|
||||
code: 'INVALID_SHAPE',
|
||||
path: '$.offClusterStorage.storageClass',
|
||||
message: 'unsupported storage class',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const wal = input['walArchiveIntervalMinutes'];
|
||||
const pitr = input['pitrRetentionDays'];
|
||||
if (pitr !== 0 && wal === null) {
|
||||
issues.push({
|
||||
code: 'PITR_REQUIRES_WAL',
|
||||
path: '$.pitrRetentionDays',
|
||||
message: 'PITR retention requires WAL archival',
|
||||
});
|
||||
}
|
||||
if (wal !== null && pitr === 0) {
|
||||
issues.push({
|
||||
code: 'WAL_REQUIRES_PITR',
|
||||
path: '$.walArchiveIntervalMinutes',
|
||||
message: 'WAL archival requires positive PITR retention',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isPositiveInteger(input['targetRpoMinutes']) &&
|
||||
isPositiveInteger(input['baseBackupIntervalHours']) &&
|
||||
(wal === null || isPositiveInteger(wal))
|
||||
) {
|
||||
const mechanismMinutes = wal === null ? input['baseBackupIntervalHours'] * 60 : wal;
|
||||
if (mechanismMinutes > input['targetRpoMinutes']) {
|
||||
issues.push({
|
||||
code: 'RPO_BETTER_THAN_MECHANISM',
|
||||
path: '$.targetRpoMinutes',
|
||||
message: `configured mechanism can only support ${mechanismMinutes} minutes`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tier === 'high-assurance') {
|
||||
const weakened =
|
||||
!isPositiveInteger(input['targetRpoMinutes']) ||
|
||||
input['targetRpoMinutes'] > 15 ||
|
||||
!isPositiveInteger(input['targetRtoMinutes']) ||
|
||||
input['targetRtoMinutes'] > 4 * 60 ||
|
||||
!isPositiveInteger(input['baseBackupIntervalHours']) ||
|
||||
input['baseBackupIntervalHours'] > 24 ||
|
||||
!isPositiveInteger(wal) ||
|
||||
wal > 5 ||
|
||||
!isNonnegativeInteger(pitr) ||
|
||||
pitr < 35 ||
|
||||
!isPositiveInteger(input['restoreTestIntervalDays']) ||
|
||||
input['restoreTestIntervalDays'] > 30 ||
|
||||
!isPositiveInteger(input['breakGlassDrillIntervalDays']) ||
|
||||
input['breakGlassDrillIntervalDays'] > 90;
|
||||
if (weakened) {
|
||||
issues.push({
|
||||
code: 'HIGH_ASSURANCE_WEAKENED',
|
||||
path: '$',
|
||||
message: 'high-assurance posture may be strengthened but not weakened',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) return { ok: false, issues };
|
||||
return { ok: true, value: input as unknown as RecoveryPostureV1 };
|
||||
}
|
||||
|
||||
export interface RecoveryPostureOverrideAuditV1 {
|
||||
actorId: string;
|
||||
reason: string;
|
||||
effectiveAt: string;
|
||||
policyRevision: string;
|
||||
previous: RecoveryPostureV1;
|
||||
next: RecoveryPostureV1;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"incremental": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"drizzle-orm": ["../../packages/db/node_modules/drizzle-orm/index.d.ts"],
|
||||
"drizzle-orm/pg-core": ["../../packages/db/node_modules/drizzle-orm/pg-core/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["contracts/*.ts"]
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
VERDICT: GO
|
||||
|
||||
# Native Kanban/SOT canon independent re-review 2
|
||||
|
||||
Independent read-only re-review of the complete updated staged canon. Prior proposal-audit blocker is closed; no KCR-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.
|
||||
@@ -1,357 +0,0 @@
|
||||
# Independent Review — Native Kanban/SOT Canon
|
||||
|
||||
**Reviewer:** `enhance-sol` (independent of author `planner-sol`)
|
||||
**Date:** 2026-07-13
|
||||
**Review mode:** design/contract only; read-only against the staged canon
|
||||
**Source plan:** `/home/hermes/agent-work/planning/mosaic-native-kanban-sot-plan.md` (`sha256:96ea4fb91436ec9a53f371d27276e27f62ecf817662599ff9152df0db55296e5`)
|
||||
**Canon reviewed:** every listed artifact under `/home/hermes/agent-work/planning/kanban-canon/`, including the four TypeScript contracts; the author scratchpad was also read as validation context.
|
||||
|
||||
## Executive verdict
|
||||
|
||||
# NO-GO
|
||||
|
||||
The canon is not freeze-ready. I found **8 BLOCKERs**, **7 MAJORs**, and **1 MINOR**. The prose preserves the ratified authority model well, but the frozen types/schema leave concrete fail-closed, approval, fencing, tenant, outage-proposal, migration, and parallelization gaps. Those gaps would force implementation lanes either to invent contract semantics or to ship paths that violate fixed invariants.
|
||||
|
||||
### Blocking findings
|
||||
|
||||
1. Health/write authorization can be represented as contradictory, stale, or caller-asserted state.
|
||||
2. Coordinator failures collapse authoritative denial, unknown transport outcome, and version conflict into one permissive shape.
|
||||
3. Assignment proposals and approval proofs have no authoritative relational binding; lease acquisition accepts a forgeable proof DTO.
|
||||
4. Fencing uniqueness is present, but monotonic fencing and same-task lease/checkpoint binding are not.
|
||||
5. Workspace-safe accountable-owner, assignment-principal, and evidence/artifact relationships are not frozen.
|
||||
6. Attributable post-recovery proposals have neither a canonical table nor command contract.
|
||||
7. The slice graph starts schema/UI work before prerequisite threat and exact API/DTO freezes and contradicts coder4 lane order.
|
||||
8. P0 claims a migration map while publishing only generic rules; the concrete N-1 transition from current `origin/main` is absent.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### KCR-001 — BLOCKER — “Healthy” is not a proof and can be contradictory or stale
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/health-state.v1.ts:21-31` — `KanbanHealthResponseV1` permits every combination of `state`, `readHealthProven`, and `writeHealthProven`.
|
||||
- `contracts/mechanical-coordinator.v1.ts:40-49` — `CoordinatorContextV1` accepts a caller-supplied `healthState` enum only.
|
||||
- `contracts/mechanical-coordinator.v1.ts:255-293` — every Coordinator operation, including mutating operations, accepts that context.
|
||||
- `SHARED-CONTRACT.md:171-184` — mutations are allowed only after live PostgreSQL read/write probes.
|
||||
|
||||
**Violation**
|
||||
|
||||
Fixed invariant 2 / `REQ-SOT-002`: mutations must fail closed unless write health is positively proven. The current type permits `{ state: 'healthy', writeHealthProven: false }`, and the Coordinator mutation boundary can be invoked with a stale or fabricated `{ healthState: 'healthy' }`. A Valkey/client-derived enum could therefore be mistaken for write authorization.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
1. Make `KanbanHealthResponseV1` a discriminated union with only these legal combinations: `healthy => read=true/write=true`, `read-only-degraded => read=true/write=false`, and `write-unavailable => read=false/write=false`.
|
||||
2. Do not accept write authority from a public DTO. Require Gateway/domain code to obtain and revalidate a fresh internal PostgreSQL write-health proof at mutation time (including `checkedAt`, bounded validity/policy revision, and transaction-local enforcement).
|
||||
3. Split pure evaluation context from mutation context; mutation methods must accept only an unforgeable/internal healthy context or perform the probe themselves.
|
||||
4. Add negative contract tests for contradictory state, expired proof, Valkey-only liveness, and caller-forged `healthy`.
|
||||
|
||||
### KCR-002 — BLOCKER — Coordinator error shape can conflate denial, unknown outcome, and conflict
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/mechanical-coordinator.v1.ts:184-216` — one `CoordinatorFailureV1` allows every code to pair with arbitrary `retryable` and either `requestOutcome` value.
|
||||
- `contracts/health-state.v1.ts:51-106` — the Gateway health contract correctly distinguishes deliberate denial, transport uncertainty, and version conflict.
|
||||
- `SHARED-CONTRACT.md:177-216` — frozen client semantics require those cases not to be conflated.
|
||||
|
||||
**Violation**
|
||||
|
||||
Charter E and `REQ-SOT-002`. The current Coordinator result can legally encode `WRITE_HEALTH_UNPROVEN` as `retryable: true, requestOutcome: 'unknown'`, or `VERSION_CONFLICT` as retryable. That permits blind retry or a false “unknown” outcome after an authoritative fail-closed denial.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Replace `CoordinatorFailureV1` with a discriminated union keyed by code/kind:
|
||||
|
||||
- deliberate health denial: `not_applied`, `retryable:false`;
|
||||
- version conflict: `not_applied`, `retryable:false`, current version;
|
||||
- stale fence/session/eligibility/approval failures: exact non-retry semantics;
|
||||
- transport failure: a separate `retryable_transport_error`, `unknown`, same idempotency key.
|
||||
|
||||
Reuse or map explicitly to `KanbanMutationFailureV1`, and add exhaustive client tests proving 503 authoritative bodies, 502/504/timeouts, and 409 cannot cross-map.
|
||||
|
||||
### KCR-003 — BLOCKER — Approval proof is forgeable and is not linked to the persisted proposal
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/mechanical-coordinator.v1.ts:107-137` — proposal and approval DTOs.
|
||||
- `contracts/mechanical-coordinator.v1.ts:265-270` — `acquireApprovedLease` accepts the entire `ApprovalProofV1` by value.
|
||||
- `contracts/kanban-schema.v1.ts:650-688` — `task_assignments` has no proposal expiry, task version, session binding, or proposal/approval FK.
|
||||
- `contracts/kanban-schema.v1.ts:823-863` — `approval_decisions` can target only a task or mission and has no proposal/assignment relation.
|
||||
- `SHARED-CONTRACT.md:128-137` — lease acquisition requires authoritative approval under the exact policy revision.
|
||||
|
||||
**Violation**
|
||||
|
||||
Fixed invariant 5 and `REQ-COORD-002/003`. A caller can construct an `ApprovalProofV1`; the schema cannot prove that it belongs to the proposal, workspace, task version, agent/session, unexpired policy revision, or still-current approval. The DTO state vocabulary (`awaiting_approval | policy_pre_authorized`) also does not map directly to the persisted assignment states (`proposed | approved | ...`).
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Persist one authoritative proposal/assignment identity with task version, target agent/session, expiry, state, and policy revision. Add a workspace-aware approval relation to that identity. Change lease acquisition to accept IDs, then reload and lock proposal + approval + task inside PostgreSQL and verify workspace, current version, target session, state, expiry, and policy revision before creating the lease. Freeze one state vocabulary across schema and DTOs.
|
||||
|
||||
### KCR-004 — BLOCKER — Fencing is unique but not monotonically increasing; relational binding is incomplete
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/kanban-schema.v1.ts:694-743` — `task_leases` has positive/unique fencing tokens but no monotonic per-task counter.
|
||||
- `contracts/kanban-schema.v1.ts:748-784` — checkpoints independently carry task, lease, and fencing token.
|
||||
- `contracts/kanban-schema.v1.ts:905-910` — token equality is deferred to prose; same-task lease binding is not stated.
|
||||
- `contracts/mechanical-coordinator.v1.ts:140-175` — worker commands depend on fencing safety.
|
||||
|
||||
**Violation**
|
||||
|
||||
Fixed invariant 12 / `REQ-COORD-003`. Uniqueness permits token 10 followed by token 9. A lease can reference assignment A while naming task B in the same workspace, and a checkpoint can reference lease A while naming task B. `bigint(..., { mode: 'number' })` also eventually loses integer precision in JavaScript.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Add a durable per-task fencing counter (or equivalent PostgreSQL sequence row) incremented atomically under task lock and use the returned value for every new lease. Add workspace-aware composite constraints tying lease to its exact task+assignment and checkpoint to exact task+lease+fence. Use bigint-safe representation (`bigint`/serialized decimal), and test monotonicity, concurrent claims, stale lower tokens, and mismatched same-workspace IDs.
|
||||
|
||||
### KCR-005 — BLOCKER — Hard tenant boundary is not frozen for several polymorphic relationships
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/kanban-schema.v1.ts:315-318` and `475-478` — project/task accountable owners are unvalidated `(kind, text id)` pairs.
|
||||
- `contracts/kanban-schema.v1.ts:659-663` — assignment principals are unvalidated `(kind, text id)` pairs.
|
||||
- `contracts/kanban-schema.v1.ts:758` and `841` — checkpoint/evidence artifact relationships are JSON arrays without workspace-aware FKs.
|
||||
- `SHARED-CONTRACT.md:89-96` — only selected polymorphic checks are delegated to domain transactions; owner/principal/evidence checks are not included.
|
||||
- `REQUIREMENTS.md:101-108` — every relationship must reject cross-workspace IDs.
|
||||
|
||||
**Violation**
|
||||
|
||||
Fixed invariant 7 / `REQ-TEN-001` and `REQ-ID-001`. The frozen schema can name a team or agent from another workspace as owner/assignee, and can embed foreign-workspace artifact IDs in checkpoint or approval evidence arrays. A global user ID is also insufficient without active workspace membership validation.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Use workspace-aware owner/assignment join tables or separate nullable user/team/agent columns with exactly-one checks and composite FKs where possible. Model checkpoint/evidence artifact links as workspace-scoped join rows, or freeze explicit transaction checks for every ID. Require active workspace membership for user principals and workspace-agent/session consistency for agent principals. Add DB/repository/API/Coordinator cross-workspace negative tests without existence oracles.
|
||||
|
||||
### KCR-006 — BLOCKER — Post-recovery outage proposals have no canonical persistence or command surface
|
||||
|
||||
**Location**
|
||||
|
||||
- `REQUIREMENTS.md:93-99` — proposal submission and authorized accept/reject are required.
|
||||
- `SHARED-CONTRACT.md:26-29` — outage notes may return only as authenticated proposals.
|
||||
- `SHARED-CONTRACT.md:243-267` — the thin command/query contract contains no proposal submit/get/accept/reject operations.
|
||||
- `contracts/kanban-schema.v1.ts:1-916` — no proposal table captures proposed command, target/version, attribution, lifecycle, or decision.
|
||||
- `TASKS.md:99-108` — KBN-110 does not own an outage-proposal command path.
|
||||
|
||||
**Violation**
|
||||
|
||||
Fixed invariant 4 / `REQ-SOT-004`. An implementation lane would have to invent storage or misuse artifacts/approval gates. Either path risks silently applying an outage note or creating shadow state.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Add a workspace-scoped `change_proposals`/`outage_proposals` contract with authenticated proposer, source note digest, target aggregate, expected version, proposed typed command/payload, pending/accepted/rejected state, decision actor/reason/time, idempotency key, and audit linkage. Add explicit submit/query/accept/reject Gateway commands. Acceptance must execute the normal command in a healthy transaction; a proposal itself can never claim, order, satisfy a gate, or mutate the target.
|
||||
|
||||
### KCR-007 — BLOCKER — Parallel slice ordering is not freeze-safe and contains a direct lane-order contradiction
|
||||
|
||||
**Location**
|
||||
|
||||
- `TASKS.md:43-60` — dependency graph makes KBN-010 and KBN-100 siblings.
|
||||
- `TASKS.md:88-97` — KBN-100 nevertheless depends on KBN-010 threat findings that alter constraints.
|
||||
- `SHARED-CONTRACT.md:243` and `INDEX.md:44-50` — exact route names/DTO placement remain unresolved.
|
||||
- `TASKS.md:110-130` — KBN-120/130 depend on a frozen endpoint/DTO contract, while mocks may begin before KBN-110 lands.
|
||||
- `TASKS.md:145-153` — KBN-200 says lane-serial after KBN-120.
|
||||
- `TASKS.md:248-254` — wave table runs KBN-200 before KBN-120.
|
||||
|
||||
**Violation**
|
||||
|
||||
Charter C and the mandatory freeze-before-parallelize gate. Schema can begin before tenant/threat findings are complete; web/CLI consumers have only semantic operations, not exact DTO/endpoint contracts; coder4 has two opposite legal orders. This does not create same-file edits immediately, but it guarantees contract invention or rework across active lanes.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
1. Make KBN-010 (or an explicit constraint-impact gate from it) a completed prerequisite of KBN-100.
|
||||
2. Add a small serialized KBN-105 endpoint/DTO/endpoint-registry freeze, with exact request/response/error DTOs, before KBN-120 and KBN-130 implementation.
|
||||
3. Choose one coder4 lane order and use it consistently in slice text, graph, and wave table.
|
||||
4. Name the exact MCP-owned files or assign their Gateway changes to coder3 before coder4 starts.
|
||||
|
||||
### KCR-008 — BLOCKER — Claimed P0 migration map is absent; concrete N-1 hazards remain unresolved
|
||||
|
||||
**Location**
|
||||
|
||||
- `MISSION-MANIFEST.md:153-157` — P0 says to publish a migration map and states the build hold is lifted at line 3.
|
||||
- `SHARED-CONTRACT.md:101-121` — only generic expand/backfill/contract rules are supplied.
|
||||
- `contracts/kanban-schema.v1.ts:1-916` — target-state declarations reuse live table names and make target fields required.
|
||||
- Current foundation evidence: `origin/main:packages/db/src/schema.ts:120-301` has no workspace keys, nullable project/mission links, legacy text status vocabularies, `tasks.tags`, `tasks.assignee`, `tasks.due_date`, mission JSON milestones/config, `mission_tasks.status`, and legacy agent fields.
|
||||
|
||||
**Violation**
|
||||
|
||||
Charter D / `REQ-MIG-001` and the P0 exit claim. The generic rule is correct, but coder2 lacks the required field-by-field transition map. A direct Drizzle reconciliation could attempt type narrowing/status conversion, add required workspace/project/owner columns too early, or drop legacy columns before N-1 readers and writers are retired.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Publish a concrete current-main delta map before lifting the hold. For each existing table/column, specify expand, backfill, compatibility read/write, switch, and contract release. At minimum cover:
|
||||
|
||||
- nullable-first `workspace_id`, required project/owner fields, and workspace backfill;
|
||||
- legacy task/project/mission status aliases or shadow columns before v1 emission;
|
||||
- `mission_tasks.status` read retirement and write-source prohibition;
|
||||
- mapping/retention for tags, assignee, due date, mission description/config/milestones, and agent fields;
|
||||
- current milestone circular FK ordering;
|
||||
- empty, production-shape, partial-resume, and rollback/downgrade tests already named in §4.
|
||||
|
||||
Explicitly require legacy columns to remain in the unified Drizzle declaration during the expand/N-1 window.
|
||||
|
||||
### KCR-009 — MAJOR — Dependency uniqueness permits parallel duplicate edges
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/kanban-schema.v1.ts:561-567` — unique key includes `dependencyType`.
|
||||
- `SHARED-CONTRACT.md:47-49` — calls for a unique directed edge.
|
||||
- `REQUIREMENTS.md:142-149` — duplicate edge attempts must fail.
|
||||
|
||||
**Violation**
|
||||
|
||||
`REQ-DEP-001`. The same predecessor/successor pair can be inserted three times, once per dependency type. That is not a unique directed edge and complicates readiness semantics.
|
||||
|
||||
**Minimal fix**
|
||||
|
||||
Make `(workspace_id, predecessor_task_id, successor_task_id)` unique independent of type, or explicitly redefine the requirement as one edge per type and freeze deterministic multi-edge completion semantics. The source plan says unique directed edge, so the former is the minimal faithful fix.
|
||||
|
||||
### KCR-010 — MAJOR — Same-workspace planning relationships can contradict the project hierarchy
|
||||
|
||||
**Location**
|
||||
|
||||
- `contracts/kanban-schema.v1.ts:325` — `projects.currentMilestoneId` has no FK in the declaration.
|
||||
- `contracts/kanban-schema.v1.ts:427-448` — mission/milestone association checks workspace but not common project.
|
||||
- `contracts/kanban-schema.v1.ts:490-516` — a 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.**
|
||||
@@ -1,38 +0,0 @@
|
||||
# #751 Native Kanban/SOT canonical publication — Ultron final gate
|
||||
|
||||
**Verdict: GO** — zero BLOCKER/HIGH findings.
|
||||
|
||||
## Scope / integrity
|
||||
|
||||
- Reviewed `/home/hermes/agent-work/stack-kanban-canon` staged delta only: exactly 16 documentation/contract artifacts; no unstaged delta; `git diff --cached --check` passes.
|
||||
- This is a publication canon, not a runtime implementation. The explicit implementation hold prevents feature work until canon merge and prerequisite release (`docs/requirements/native-kanban-sot.md:8-9`; `docs/native-kanban-sot/TASKS.md:45-67`).
|
||||
|
||||
## Acceptance mapping and findings
|
||||
|
||||
| Requirement area | Final evidence / result |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Sole PostgreSQL SOT, generated projections, outage proposals | Requirements D3/D4 and fixed invariants prohibit alternate writers and import (`docs/requirements/native-kanban-sot.md:22-23,32-44`). Health contract keeps public observation separate from branded transaction-local proof (`contracts/health-state.v1.ts:44-84`) and freezes 503/409/502/504 mappings (`:91-184`). Proposal table uses workspace-aware event FKs (`contracts/kanban-schema.v1.ts:847-908`); exact submission/acceptance transaction semantics are specified (`SHARED-CONTRACT.md:81-89`). PASS. |
|
||||
| Workspace tenancy, planning, assignments, evidence | Workspace-composite task and proposal relations plus active-member rules are explicit (`SHARED-CONTRACT.md:40-48`; `kanban-schema.v1.ts:587-637,875-908`). Lease/checkpoint relations bind workspace/task/assignment/session/fence, with one active lease and bigint fencing (`:1062-1114`). PASS. |
|
||||
| Coordinator, gates, concurrency/recovery | Pure Coordinator has snapshot-only decision methods (`mechanical-coordinator.v1.ts:186-198`); persistence port owns locked ID validation and recovery (`:371-407`). Requirements forbid Coordinator scope/gate/certification/merge authority and Certifier merge authority (`requirements:39-40`; `MISSION-MANIFEST.md` authority table). Recovery validator rejects unknown fields, PITR/WAL/RPO/storage/high-assurance violations (`recovery-posture.v1.ts:193-369`). PASS. |
|
||||
| Migration/N-1/API/task decomposition | N-1 expand/backfill/compatibility/switch/contract order and proposal DDL sequence are concrete (`SHARED-CONTRACT.md:69-115`). Frozen exact Gateway/DTO registry and non-overlapping lane ownership/prerequisites are present (`SHARED-CONTRACT.md:244-282`; `TASKS.md:45-67,81-259`). PASS. |
|
||||
| Documentation / seven owner decisions / evidence | 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.
|
||||
@@ -1,368 +0,0 @@
|
||||
# Native Kanban and Canonical Task SOT — Canonical Requirements
|
||||
|
||||
**Status:** RATIFIED and independently approved for canonical publication under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
**Date:** 2026-07-14
|
||||
**Decision owner:** Jason
|
||||
**Publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage)
|
||||
**Implementation foundation:** current `mosaicstack/stack` main only
|
||||
**Implementation hold:** no feature implementation begins until this canon is squash-merged to `main` with terminal-green CI.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Deliver Mosaic Stack's native project/task control plane and thin writable Kanban on one authoritative PostgreSQL model. This document formalizes the ratified source plan; it does not create a parallel design.
|
||||
|
||||
Normative terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are binding as used here.
|
||||
|
||||
## 2. Ratified decisions
|
||||
|
||||
| # | Ratified decision | Canonical result |
|
||||
| --- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| D1 | Foundation | Extend current `mosaicstack/stack` main with its existing Drizzle/PostgreSQL, NestJS Gateway, Next.js, Better Auth, and Valkey/BullMQ conventions. No greenfield service and no Prisma revival. |
|
||||
| D2 | Tenant boundary | `workspace_id` is the hard tenant boundary from the first migration. Teams are authorization groups inside a workspace, never tenant substitutes. |
|
||||
| D3 | Outage authority — Option A with amendment | PostgreSQL is the sole writable SOT and mutations fail closed whenever DB write-health cannot be proven. The amendment permits deployment-specific **recovery posture only**; it does not permit an alternate writer. Human outage notes are attributable post-recovery proposals, never shadow state. |
|
||||
| D4 | Generated files | `TASKS.md`, `mission.json`, and any file export are generated, read-only, non-authoritative, and never import sources. Generate on demand; commit only where repository review policy requires a snapshot. |
|
||||
| D5 | Status model | Task statuses are `backlog`, `ready`, `in_progress`, `blocked`, `in_review`, `done`, `cancelled`. Runtime readiness is orthogonal and computed. |
|
||||
| D6 | Coordinator approval | Hybrid: manual Project Sub-Orchestrator approval by default; automatic routing only under an explicit, approved, versioned low-risk policy. |
|
||||
| D7 | Initial migration scope | Project, mission, milestone, task, tags/archive, dependency, assignment, outage proposal, evidence/link, and orchestration state only. Calendar, email, GLPI cache, and personal-brain features remain out of scope. |
|
||||
|
||||
## 3. Fixed invariants — every deployment
|
||||
|
||||
These are not tier settings and cannot be weakened by deployment configuration.
|
||||
|
||||
1. PostgreSQL is the **sole writable source of truth**.
|
||||
2. The implementation uses Drizzle on current stack main.
|
||||
3. Kanban and orchestration mutations **fail closed** unless DB write-health is positively proven `healthy`.
|
||||
4. No failed mutation is redirected to Markdown, JSON, browser storage, Valkey, queue payloads, scratchpads, or provider issues.
|
||||
5. `TASKS.md` and all file exports are generated, read-only, non-authoritative, and never parsed for import.
|
||||
6. Human notes created during an outage become attributable proposals only after recovery. They do not reserve work, change status, satisfy a gate, or establish ordering.
|
||||
7. Valkey is derived, expendable coordination infrastructure. PostgreSQL retains task truth, leases, fencing, audit, and the transactional outbox.
|
||||
8. The Mechanical Coordinator is non-LLM and deterministic. It may evaluate eligibility, dependencies, approval policy, leases, fencing, heartbeat, retry, expiry, and quarantine. It cannot invent scope, alter acceptance criteria, waive gates, certify, or merge.
|
||||
9. **Certifier** is the final independent quality-gate role. Certifier may pass, reject, or escalate with evidence; it has no merge authority.
|
||||
10. Every business and orchestration record is workspace-scoped; cross-workspace relationships are rejected.
|
||||
11. Every mutation is idempotent and expected-version checked where it changes an aggregate.
|
||||
12. Stale worker mutations are rejected by monotonically increasing fencing tokens.
|
||||
13. Audit events are append-only and attributable; authoritative state is reconstructable from PostgreSQL without Valkey or files.
|
||||
|
||||
## 4. Configurable recovery posture only
|
||||
|
||||
Deployment tiers configure durability and operational recovery targets. They never configure SOT authority, fail-open writes, or gate bypass.
|
||||
|
||||
### 4.1 Tier defaults
|
||||
|
||||
| Setting | Lite | Standard | High-assurance |
|
||||
| --------------------------- | --------------------------------------: | ------------------------------------------------------------: | ----------------------------------------------------------------------: |
|
||||
| Target RPO | 24 hours | 1 hour | **15 minutes** |
|
||||
| Target RTO | 24 hours | 8 hours | **4 hours** |
|
||||
| Base backup cadence | Daily | Daily | **Daily** |
|
||||
| WAL archive cadence | Disabled | Every 15 minutes | **Every 5 minutes** |
|
||||
| PITR retention | 0 days / disabled | 14 days | **35 days** |
|
||||
| Restore test frequency | Quarterly | Quarterly | **Monthly** |
|
||||
| Break-glass drill frequency | Annually | Semiannually | **Quarterly** |
|
||||
| Off-cluster storage | One encrypted off-cluster backup target | Encrypted off-cluster object storage, separate failure domain | **Encrypted off-cluster base backups and WAL, separate failure domain** |
|
||||
|
||||
A deployment MAY override defaults only through the validated recovery-posture contract. An override MUST record actor, reason, effective time, and policy revision. A claimed RPO MUST be no smaller than the actual backup/WAL mechanism can support. Enabling PITR requires WAL archival and off-cluster storage.
|
||||
|
||||
## 5. Functional requirements and acceptance criteria
|
||||
|
||||
### REQ-SOT-001 — Sole writable PostgreSQL authority
|
||||
|
||||
**Requirement:** All project, mission, milestone, task/tag/archive, dependency, assignment, execution/quarantine, lease, checkpoint, approval, outage proposal, event, link, artifact, and outbox mutations MUST commit through Gateway domain services into PostgreSQL.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Mutation journey tests show web, CLI, MCP, and agents invoke typed Gateway commands.
|
||||
- Static/process inventory finds no file, Valkey, browser, or provider issue writer acting as canonical state.
|
||||
- PostgreSQL state survives Valkey loss and reconstructs the same aggregate revisions.
|
||||
|
||||
### REQ-SOT-002 — Fail-closed mutation health
|
||||
|
||||
**Requirement:** A mutation MUST execute only while health state is `healthy`. `read-only-degraded` and `write-unavailable` MUST return the frozen deliberate-denial error contract and MUST NOT enqueue a hidden write.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Public health response is a discriminated union; contradictory state/proof combinations fail contract validation.
|
||||
- Mutation methods accept only a fresh internal PostgreSQL transaction-local write proof, never caller-asserted/public health state.
|
||||
- Negative tests reject expired proofs, policy-revision mismatch, Valkey-only liveness, and caller-forged `healthy`.
|
||||
- Fault tests force both degraded states and prove row counts, outbox, files, and Valkey remain unchanged.
|
||||
- Exact failure mapping proves authoritative 503 denial, retryable 502/504/timeout uncertainty, and 409 version conflict cannot cross-map.
|
||||
- Replaying the same idempotency key after recovery returns one canonical result.
|
||||
|
||||
### REQ-SOT-003 — Generated projections
|
||||
|
||||
**Requirement:** `TASKS.md`, `mission.json`, and other exports MUST contain a non-authoritative header, workspace/project IDs, generated time, and source revision. No production parser may mutate DB from an export.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Generated output matches the API snapshot revision.
|
||||
- Hand editing a projection fails CI validation or is overwritten by regeneration.
|
||||
- Repository search finds no import path from generated projections.
|
||||
|
||||
### REQ-SOT-004 — Attributable outage proposals
|
||||
|
||||
**Requirement:** Human outage notes MAY be captured outside the system but, after recovery, can enter Mosaic only through workspace-scoped `change_proposals` attributed to an authenticated active member. A proposal stores source-note digest, target aggregate/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, and audit links. It MUST NOT silently change canonical state.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` are composite foreign keys to `task_events(workspace_id, id)`; missing and foreign-workspace event IDs fail before commit.
|
||||
- Submission preallocates the proposal ID and atomically inserts `change_proposal.submitted` for that exact workspace/proposal with the new proposal referencing it.
|
||||
- Accept locks proposal and target, obtains fresh write proof, checks expected version, executes the normal typed command, and atomically links that command's event for the same workspace/target and proposal causation.
|
||||
- Negative tests reject missing submission events, foreign-workspace submission/acceptance events, and same-workspace events for an unrelated proposal, aggregate, target, or command.
|
||||
- Tests prove a pending/rejected proposal cannot claim/order work, satisfy a dependency/gate, or mutate any target directly.
|
||||
|
||||
### REQ-TEN-001 — Workspace hard tenancy
|
||||
|
||||
**Requirement:** Every canonical business/orchestration row MUST carry `workspace_id`. Workspace-aware constraints and authorization MUST prevent cross-tenant relationships and reads/writes.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- API, repository, import, WebSocket, and Coordinator negative tests reject foreign-workspace IDs without existence oracles.
|
||||
- Project/task owners use exactly-one user/team references; assignment principals use exactly-one user/team/agent reference; agent/session targets are workspace-consistent.
|
||||
- User owners, principals, proposers, and decision actors require ACTIVE workspace membership in the authoritative transaction.
|
||||
- Dependency, project hierarchy, assignment, lease, checkpoint, approval-evidence, link, artifact, proposal target, and both proposal-audit-event composite relationships reject mixed workspaces.
|
||||
- Tenant context is derived from authenticated authority, never accepted blindly from request data.
|
||||
|
||||
### REQ-ID-001 — Workspace identity and service scope
|
||||
|
||||
**Requirement:** Users, teams, agents, and agent sessions MUST be bound to a workspace with explicit role/capability scope. Agents MUST NOT receive raw DB credentials.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Workspace membership and service-identity tests enforce command-family scope.
|
||||
- Revoked/disabled agents and ended sessions cannot claim, heartbeat, or submit.
|
||||
|
||||
### REQ-PLAN-001 — Normalized planning hierarchy
|
||||
|
||||
**Requirement:** Canonical planning entities are projects, milestones, missions, mission-milestone associations, and tasks. A task belongs to one required project and at most one mission/milestone/parent task.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- CRUD tests preserve workspace, hierarchy, versions, and lifecycle constraints.
|
||||
- Mission membership does not duplicate task status.
|
||||
- Composite project-congruent constraints reject task→mission, task→milestone, task→parent, mission→milestone, and project→current-milestone mismatches.
|
||||
- Parent and association constraints reject cycles/orphans where applicable.
|
||||
|
||||
### REQ-TASK-001 — Canonical task fields
|
||||
|
||||
**Requirement:** Tasks MUST support title, description, structured acceptance criteria, canonical status, priority, fractional board rank, accountable owner, assigned specialist role, due/not-before dates, estimate, progress, explicit blocker, retry policy, normalized workspace tags, non-lifecycle archival (`archived_at/by/reason`), metadata, monotonic fencing counter, and optimistic version.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- API and UI round-trip every field without silent loss.
|
||||
- Current `tasks.tags`, `assignee`, and `due_date` remain declared/preserved during N-1 and backfill to the canonical model without loss.
|
||||
- Archive hides work without changing its canonical lifecycle status and requires actor/reason/time.
|
||||
- Invalid status, rank, progress, date, owner, tag, archive, or retry data is rejected.
|
||||
- Concurrent expected-version updates produce a visible conflict.
|
||||
|
||||
### REQ-TASK-002 — Fixed lifecycle and computed readiness
|
||||
|
||||
**Requirement:** Human workflow status MUST use the seven ratified values. Dependency/schedule/policy/lease/retry conditions MUST be exposed as computed readiness, not hidden status rewrites.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- A dependency becoming incomplete changes readiness but does not silently rewrite the Kanban column.
|
||||
- Readiness explanation identifies all active gates.
|
||||
- State-machine tests reject illegal transitions and require reasons for blocked/cancelled paths.
|
||||
|
||||
### REQ-DEP-001 — Dependency DAG
|
||||
|
||||
**Requirement:** Workspace-local directed dependencies MUST be unique and acyclic. A task is dependency-eligible only after every blocking predecessor is `done` and completion conditions pass.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- `(workspace_id, predecessor_task_id, successor_task_id)` is unique independent of dependency type.
|
||||
- Cycle, duplicate, self-edge, and cross-workspace attempts fail before commit.
|
||||
- Property/concurrency tests prove all blocking predecessors are evaluated.
|
||||
- UI displays dependency and readiness errors accessibly.
|
||||
|
||||
### REQ-ASN-001 — Assignment is not a lease
|
||||
|
||||
**Requirement:** Assignment history and execution leases MUST be separate records. One persisted assignment identity freezes task version, exact target agent/session (or exactly-one non-agent principal), specialist role, expiry, state, policy revision, proposer, reason, and timestamps. Approval decisions relate to that assignment with workspace-aware constraints.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- One assignment-state vocabulary is identical across schema, DTO, and engine.
|
||||
- Lease acquisition accepts IDs only, then reloads and locks assignment, approval, task, and target session to verify workspace, current task version, exact target, state, expiry, and policy revision.
|
||||
- Reassignment preserves history; assignment may exist without a lease; lease expiry does not erase ownership/evidence.
|
||||
|
||||
### REQ-AUD-001 — Semantic audit and outbox
|
||||
|
||||
**Requirement:** Mutating commands MUST append semantic `task_events` with actor, correlation, causation, idempotency key, and aggregate versions in the same transaction as state. Notifications MUST flow from a transactional outbox.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Atomicity tests prove state/event/outbox commit or roll back together.
|
||||
- Proposal submission and acceptance tests prove their workspace-bound event links identify the exact submission and executed normal command, not merely an existing event UUID.
|
||||
- Duplicate idempotency keys return the prior result without duplicate events.
|
||||
- `task_events`, checkpoints, immutable artifacts, and evidence joins are INSERT/SELECT-only for application roles; parent hard deletes are RESTRICTed.
|
||||
- Normal lifecycle uses archive/cancel, never hard delete; retention purge requires audited break-glass authority and evidence.
|
||||
- Valkey outage leaves outbox pending and later replayable.
|
||||
|
||||
### REQ-API-001 — Typed Gateway command boundary
|
||||
|
||||
**Requirement:** Gateway MUST expose workspace-safe project/task/dependency/assignment/link/artifact/change-proposal queries and explicit lifecycle commands. Generic patching MUST NOT bypass claim, heartbeat, review, certify, proposal acceptance, or completion invariants.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- KBN-105 freezes exact route, request, success, denial, conflict, and transport-normalization DTOs before CLI/web implementation.
|
||||
- DTO validation, authorization, contract, and integration tests cover each command.
|
||||
- Exact MCP-owned Gateway files are coder3-owned; coder4 consumes only frozen Gateway contracts.
|
||||
- Endpoint registry aligns web, CLI, MCP, and generated client paths.
|
||||
- Direct SQL and raw Valkey writes are absent from clients.
|
||||
|
||||
### REQ-UI-001 — Writable thin Kanban/List MVP
|
||||
|
||||
**Requirement:** Existing Tasks and Projects surfaces MUST become a real-data writable MVP with one shared query contract.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Users can create/edit/cancel/archive tasks, open task detail, and move cards within/across columns.
|
||||
- Server validates transition and persists fractional board rank.
|
||||
- Refresh, reconnect, CLI, MCP, and generated projection show the same revision.
|
||||
|
||||
### REQ-UI-002 — Tenant and work context
|
||||
|
||||
**Requirement:** UI MUST show workspace context and support filters for project, mission, milestone, status, priority, owner/specialist, due state, and tags.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Context is visible on every mutation surface.
|
||||
- Filter tests cannot expose foreign-workspace data.
|
||||
- Empty/loading/error states are explicit.
|
||||
|
||||
### REQ-UI-003 — Dependency, ownership, lease, and audit visibility
|
||||
|
||||
**Requirement:** Task detail MUST separate accountable owner, specialist assignment, active session/lease expiry, dependencies/readiness, acceptance criteria, blocker, external links, and audit timeline.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Each concept renders from its canonical endpoint.
|
||||
- A lease is never displayed as ownership or completion.
|
||||
- Conflict and stale-reconnect states require refresh rather than silent overwrite.
|
||||
|
||||
### REQ-UI-004 — Accessible interaction
|
||||
|
||||
**Requirement:** Kanban MUST support keyboard-accessible moves, non-drag alternatives, responsive layout, and semantic status/error announcements.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Keyboard journey performs every card transition available by drag.
|
||||
- Automated accessibility checks and manual responsive checks pass.
|
||||
|
||||
### REQ-COORD-001 — Non-LLM Mechanical Coordinator
|
||||
|
||||
**Requirement:** Coordinator decisions MUST be deterministic from structured data and versioned policy. It MUST NOT invoke an LLM to interpret scope or acceptance criteria.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Pure decision engine receives complete immutable snapshots and performs no ID loading, SQL, Gateway, Valkey, or recovery I/O.
|
||||
- Persistence/service adapter owns ID loading, transaction-local write proof, locking, persistence, and `recoverFromPostgres`.
|
||||
- Same snapshot and policy revision produce the same eligibility/order explanation.
|
||||
- Dependency, schedule, durable retry/quarantine, approval, role, and capacity inputs are auditable.
|
||||
- Code/config inspection finds no model/provider dependency in the scheduling engine.
|
||||
|
||||
### REQ-COORD-002 — Eligibility and approval routing
|
||||
|
||||
**Requirement:** Only `ready` tasks under active project/mission, passed dependencies/schedule/retry/release policy, and without active lease may be proposed. Manual approval is default; auto-route requires an explicit approved policy revision.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Unapproved or gated tasks are never leased.
|
||||
- Every persisted assignment proposal includes task version, exact target agent/session, expiry, state, deterministic reasons, and policy revision.
|
||||
- Approval is relationally bound to the assignment identity and cannot be supplied as a forgeable proof-by-value DTO.
|
||||
- Override/reject/reassign requires an attributable reason.
|
||||
|
||||
### REQ-COORD-003 — Atomic lease, heartbeat, fencing, and recovery
|
||||
|
||||
**Requirement:** Lease acquisition MUST be atomic in PostgreSQL, permit at most one active lease per task, atomically increment the durable per-task fencing counter under task lock, use bigint-safe tokens, require timely acknowledgement/heartbeat, and reject stale workers. Lease and checkpoint relations MUST bind the exact workspace+task+assignment/session+fence.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Concurrent claim tests yield one winner and strictly increasing fencing tokens.
|
||||
- Lower/expired tokens and mismatched same-workspace task/assignment/lease/checkpoint IDs fail.
|
||||
- Token values round-trip as bigint/decimal strings without JavaScript precision loss.
|
||||
- Coordinator restart reconstructs lease/retry/quarantine state from PostgreSQL alone.
|
||||
|
||||
### REQ-COORD-004 — Retry and quarantine
|
||||
|
||||
**Requirement:** Missing acknowledgement, agent loss, or execution failure MUST produce a deterministic release, bounded backoff retry, or quarantine outcome according to retry policy. Ambiguous/non-idempotent work requires Sub-Orchestrator action.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Durable execution state records disposition, attempt/max, next eligibility, terminal reason, actor/policy, timestamps, and version.
|
||||
- Retry budget/backoff are bounded and tested.
|
||||
- Exhausted or non-idempotent failures quarantine with workspace-scoped artifact evidence.
|
||||
- One specialist-role vocabulary is enforced across schema, sessions, assignments, DTOs, and engine.
|
||||
- No task loops indefinitely or silently returns to ready.
|
||||
|
||||
### REQ-GATE-001 — Role and authority chain
|
||||
|
||||
**Requirement:** Canonical flow is User → Interaction → Portfolio Orchestrator → Project Sub-Orchestrator → Gateway → domain services → Mechanical Coordinator → specialists → Certifier.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Role bindings and approvals are queryable and audited.
|
||||
- Coordinator cannot create scope or waive gates.
|
||||
- Certifier cannot merge or close provider artifacts.
|
||||
|
||||
### REQ-GATE-002 — Independent review and certification
|
||||
|
||||
**Requirement:** Author and reviewer MUST differ. Auth, security, tenant, secrets, and data-integrity surfaces MUST receive mandatory SecReview. Certifier is the final quality gate after remediation.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Gate tests reject author self-review and missing required SecReview.
|
||||
- Certifier receives complete traceability/evidence and returns pass/reject/escalate.
|
||||
- A Certifier pass does not grant merge authority.
|
||||
|
||||
### REQ-REC-001 — Recovery posture validation
|
||||
|
||||
**Requirement:** A deployment MUST select a validated Lite, Standard, or High-assurance posture and MAY override only recovery knobs.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Runtime invokes normative `validateRecoveryPostureV1`, not shape-only JSON Schema validation.
|
||||
- Validator rejects PITR/WAL mismatch, impossible RPO, unknown fields, non-encrypted/non-separated storage, and weakened High-assurance values.
|
||||
- A bounded recovery/infra slice owns parser wiring, override audit, mechanism verification, restore test, and break-glass evidence.
|
||||
- High-assurance defaults equal RPO 15m/RTO 4h, encrypted off-cluster WAL every 5m, 35d PITR, daily base backup, monthly restore test, and quarterly break-glass.
|
||||
|
||||
### REQ-MIG-001 — One-way shadow migration
|
||||
|
||||
**Requirement:** Migration from jarvis-brain/Vikunja MUST use inventory, immutable source snapshots/checksums, one-way shadow import, read reconciliation, write freeze, final delta, cutover, and read-only stabilization. Dual writes are forbidden.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- P0 publishes the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract map before any schema lane starts.
|
||||
- Legacy columns remain in the unified Drizzle declaration for the entire expand/N-1 window.
|
||||
- Dry-run/apply/verify modes are idempotent and workspace-safe.
|
||||
- Import lineage preserves source system/key/file/checksum/batch and rejected-record reports.
|
||||
- Empty DB, production-shape, partial-resume, downgrade/rollback, status-shadow, workspace-backfill, and `mission_tasks.status` retirement tests pass.
|
||||
- Shadow records cannot auto-dispatch.
|
||||
|
||||
### REQ-MIG-002 — Cutover and rollback safety
|
||||
|
||||
**Requirement:** Cutover MUST disable legacy writers and switch all clients to Gateway. Before first DB mutation rollback may switch authority back; afterward rollback requires freeze, DB-delta export/reconciliation, and owner decision.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Process inventory proves no active jarvis-brain/Vikunja project/task writer.
|
||||
- Cutover rehearsal meets signed reconciliation thresholds.
|
||||
- No reverse and forward sync run concurrently.
|
||||
|
||||
## 6. Explicit non-goals
|
||||
|
||||
The 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.
|
||||
@@ -1,152 +0,0 @@
|
||||
# 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.
|
||||
121
docs/scratchpads/755-mos-logical-identity-fencing.md
Normal file
121
docs/scratchpads/755-mos-logical-identity-fencing.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Issue #755 — Logical Mos identity and connector lease fencing
|
||||
|
||||
- Task: `MOS-PORT-M1-001`
|
||||
- Branch: `feat/mos-logical-identity-fencing`
|
||||
- Base: `origin/main`
|
||||
- Started: 2026-07-14
|
||||
- Working budget: 38K tokens (task ledger estimate); one implementation lane, bounded to M1.
|
||||
|
||||
## Objective
|
||||
|
||||
Implement the first runtime-portability security boundary: normalized logical-agent identity plus a PostgreSQL-durable exclusive connector lease and server-validated fencing grants.
|
||||
|
||||
## Scope
|
||||
|
||||
- Normalized identity contract independent of harness/provider-native session IDs.
|
||||
- DB migration/schema/repository for one lease per tenant/logical-agent/binding.
|
||||
- CAS acquire/takeover, monotonic epoch, TTL, heartbeat, release, expiry handling.
|
||||
- Server-derived grants bound to tenant, logical agent, binding, connector, scopes, expiry, and lease epoch.
|
||||
- Reject and credential-safely audit stale, expired, forged, unauthorized, cross-tenant, and cross-binding grants before adapter side effects.
|
||||
- Runtime adapter boundary consumes normalized lease context.
|
||||
- Unit, migration, close/reopen, concurrency, abuse, and gateway integration tests.
|
||||
- Required developer/operations documentation for schema and security behavior.
|
||||
|
||||
## Explicit exclusions
|
||||
|
||||
No checkpoint/handoff payloads, exactly-once journal/receipts, concrete Claude/Pi/Codex harness adapter, channel cutover, or full cross-harness failover E2E.
|
||||
|
||||
## Plan (TDD RED → GREEN → REFACTOR)
|
||||
|
||||
1. Map existing contracts, DB/migration conventions, gateway authorization/audit boundaries, and test infrastructure.
|
||||
2. Add failing contract/repository/concurrency/restart/abuse/gateway tests and capture RED evidence.
|
||||
3. Implement the smallest normalized contracts, schema/migration/repository, grant validator, audit sink, and gateway service/adapter boundary needed to pass.
|
||||
4. Refactor for clear invariants and credential-safe observability; rerun focused suites.
|
||||
5. Run package/repo typecheck, lint, format, and appropriate tests.
|
||||
6. Run independent code + security review, remediate, and re-review.
|
||||
7. Inspect the final diff for security/scope drift; commit; queue guard; push; open PR with `Refs #755` and exact verification; stop without merge/issue closure.
|
||||
|
||||
## Constraints and safety notes
|
||||
|
||||
- `docs/tess/TASKS.md` is orchestrator-only and will not be edited.
|
||||
- Existing dirty `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are launcher/orchestrator state and will not be staged or altered intentionally.
|
||||
- No client-supplied identity may confer authority.
|
||||
- No credential, token, or raw grant material may be persisted to audit/log output.
|
||||
- Existing authorization checks remain intact; fencing is an additional fail-closed layer.
|
||||
|
||||
## Assumptions resolved from existing architecture
|
||||
|
||||
- `ASSUMPTION:` M1 exposes no public lease endpoint. The gateway service is an internal policy surface with deny-all default policy because concrete connector activation/cutover is explicitly deferred.
|
||||
- `ASSUMPTION:` Fencing epochs use PostgreSQL `bigint` and cross-module decimal strings, preserving JSON portability without JavaScript number precision loss.
|
||||
- `ASSUMPTION:` Process-local grant provenance intentionally fails closed across restart; durable lease/epoch state survives and fresh grants require current policy + lease validation.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
RED observed before implementation:
|
||||
|
||||
- `corepack pnpm --filter @mosaicstack/types exec vitest run src/agent/connector-lease.dto.spec.ts` → failed to load missing `connector-lease.dto.js`.
|
||||
- `corepack pnpm --filter @mosaicstack/agent exec vitest run src/connector-lease.test.ts` → failed to load missing `connector-lease.js`.
|
||||
- Gateway focused tests failed before implementation because the new repository/service boundaries did not exist (workspace dependencies were then built before behavioral GREEN runs).
|
||||
|
||||
GREEN to date:
|
||||
|
||||
- Types contract: 6/6 passed.
|
||||
- Agent grant/fencing unit suite: 5/5 passed.
|
||||
- Gateway PGlite repository + policy/side-effect integration: 7/7 passed; 1 real-PostgreSQL test skipped when `DATABASE_URL` absent.
|
||||
- Real PostgreSQL focused run with configured `DATABASE_URL`: 1/1 passed (credential value not emitted in reports).
|
||||
|
||||
## Documentation checklist
|
||||
|
||||
- [x] `docs/PRD.md` contains current MOS-PORT M1 scope and acceptance criteria.
|
||||
- [x] Developer architecture: `docs/architecture/mos-runtime-portability-m1.md`.
|
||||
- [x] Admin/operations guidance: `docs/guides/mos-connector-lease-operations.md`.
|
||||
- [x] `docs/SITEMAP.md` links both pages.
|
||||
- [x] No user-guide change: M1 exposes no user-facing flow or channel cutover.
|
||||
- [x] No OpenAPI/endpoint-index change: M1 adds no HTTP endpoint.
|
||||
- [x] Migration/restart/rollback safety and credential-safe audit constraints documented.
|
||||
- [x] Canonical source remains in-repo; no external publishing action is in scope.
|
||||
- [x] Independent review confirms documentation matches implementation; implementation-specific findings were remediated.
|
||||
|
||||
## Independent review and remediation
|
||||
|
||||
Codex code/security review ran in multiple rounds. Findings and root-cause remediations:
|
||||
|
||||
1. Policy could not inspect requested scope/TTL → policy subject now receives normalized requested scopes and explicit requested TTL.
|
||||
2. Unbounded authority lifetime → hard defaults cap leases at 5 minutes and grants at 30 seconds; overrides may only tighten; over-limit tests added.
|
||||
3. Cross-tenant denial could audit under submitted tenant → mismatch audit uses authenticated tenant plus sanitized `untrusted` target metadata; integration assertion added.
|
||||
4. Malformed forged grant could break the denial/audit path → runtime-safe shape validation with sanitized fallback audit; malformed-input test added.
|
||||
5. Gateway integration test depended on prior test state → denial test now seeds a unique binding itself; isolated `-t` run passed.
|
||||
6. Reviewer repeatedly identified launcher-generated `.mosaic/orchestrator/*` state; those files remain unstaged and excluded from the implementation commit.
|
||||
|
||||
Latest independent security review: no critical/high/medium/low findings. Final commit-level code review remains to run after the intended diff is committed without launcher state.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- Focused contracts/fencing: types 6/6; agent 9/9.
|
||||
- Gateway focused PGlite repository/policy integration: 7/7; isolated denial test 1/1.
|
||||
- Real PostgreSQL close/reopen/CAS test: 1/1 with configured `DATABASE_URL`.
|
||||
- Root `corepack pnpm typecheck`: 42/42 Turbo tasks passed.
|
||||
- Root `corepack pnpm lint`: 23/23 Turbo tasks passed.
|
||||
- Root `corepack pnpm format:check`: all matched files passed.
|
||||
- Root `corepack pnpm test`: 42/42 Turbo tasks passed; gateway 616 passed / 12 environment-gated skipped; DB 19 passed / 7 environment-gated skipped; Mosaic 650 passed.
|
||||
|
||||
## Known residual risks
|
||||
|
||||
- Concrete connector policies and Claude/Pi/Codex adapters are intentionally deferred; production policy defaults deny-all.
|
||||
- Gateway pre-side-effect validation cannot make an external system exactly-once. Adapters must propagate/enforce the epoch at downstream effect boundaries; receipts/journaling are later #754 scope.
|
||||
- Migration rollback is additive-only; dropping lease/audit tables is intentionally manual to avoid destroying authority/audit evidence.
|
||||
|
||||
## Commit-level review remediation
|
||||
|
||||
- Commit-level Codex code review found one `should-fix`: heartbeat, release, and grant issuance authorized caller-supplied lease fields before canonical normalization.
|
||||
- TDD RED: the isolated gateway policy-boundary test showed mixed-case/padded logical agent, binding, connector, scope, and epoch values reaching policy unchanged.
|
||||
- Remediation: exported the coordinator's canonical lease normalizer and applied it at the gateway boundary before tenant/policy checks and coordinator dispatch for heartbeat, release, and grant issuance.
|
||||
- GREEN: isolated policy test 1/1; focused types 6/6, agent 9/9, gateway 8/8; root typecheck 42/42, lint 23/23, format check passed, and root tests 42/42 (gateway 617 passed / 12 environment-gated skipped).
|
||||
- Commit-level security review remained clean: no critical/high/medium/low findings.
|
||||
|
||||
## Durable grant-expiry review remediation
|
||||
|
||||
- Final commit review found a second `should-fix`: grant expiry was capped against submitted lease metadata after current-authority validation, rather than the durable lease row.
|
||||
- TDD RED: a crafted same-authority lease with a later submitted expiry produced a grant expiring after the durable row.
|
||||
- Remediation: grant authority fields and expiry now derive from the durable current lease; submitted scopes remain an additional narrowing constraint.
|
||||
- GREEN: focused agent fencing suite 10/10.
|
||||
@@ -1,48 +0,0 @@
|
||||
# #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.
|
||||
@@ -1,57 +0,0 @@
|
||||
# #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.
|
||||
@@ -43,3 +43,4 @@
|
||||
| TESS-M5-002 | done | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | coder3 | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001. **Mos-DISPATCHED to coder3 2026-07-13** ("M4 complete; advancing to M5") — dispatched AHEAD of TESS-M4-V passing; the M4-V-status-vs-#710-CLOSED dependency reconciliation is pending Mos ruling (tracked, not orchestrator-decided). **DELIVERED as PR #742** — 4 new files docs/tess/M5-MIGRATION-{INVENTORY,CUTOVER,ROLLBACK,RETENTION-DEPRECATION}.md, base main b7b0f508, frozen head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd. Docs-only; tracking-control trio (MISSION-MANIFEST/TASKS/VERIFICATION-MATRIX) UNTOUCHED; command-authz byte-identical a9f829e7; no live creds. **CI pipeline 1773 SUCCESS** (repo 47, refs/pull/742/head, commit==head). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd (Gitea comment 17108)** — evidence claims verified to trace to landed Hermes adapter / capability matrix, gateway registry/reachability, operator-memory scope path, Mos coordination boundary; docs do NOT over-claim transcript/profile import, schema migration, unsupported-capability enablement, production cutover, or deprecation completion. Head independently verified UNMOVED at b5e9d0e528a5 post-ROR (live Gitea), base main, mergeable=true. **#742 MERGED by Mos → main 5789711e. Deliverable docs LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** |
|
||||
| TESS-M5-003 | done | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate. **DELIVERED as PR #746** (branch feat/tess-docs, base main, 7 docs-only files: docs/openapi-tess.yaml + docs/tess/{ADMIN,DEVELOPER,OPERATIONS,PLUGIN,USER}-GUIDE.md + M5-003-DOCUMENTATION-CHECKLIST.md). 4-round revise-loop (heads 470eb911→c9f69300→7aea94e2→25b9d642) converged: OpenAPI covers interaction routes + SSE /sessions/{sessionId}/stream + Mos coord (/api/coord/mos/handoff,/observe,/result) + memory preferences/insights/search; request-body schemas aligned to real DTOs (Send requires content+idempotencyKey, Stop requires approvalRef, Insight requires only content, MosHandoff body requires idempotencyKey+summary); checklist accurate. **CI pipeline 1786 SUCCESS** (repo 47, refs/pull/746/head, commit==head 25b9d642). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head 25b9d642b939014b3efd61826e7524cafc6ffc2e (Gitea comment 17170)** — docs-only, tracking-trio untouched, command-authz byte-identical a9f829e7, no false coverage claims. Head verified UNMOVED at 25b9d642 (live Gitea, not worker-reported), base main, mergeable=true. **#746 MERGED by Mos → main bc8016c8314ec3a4b6ebc2fec5d9f276fca3327a. Documentation gate LANDED.** GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending. |
|
||||
| TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence |
|
||||
| MOS-PORT-M1-001 | in-progress | Implement logical Mos identity, PostgreSQL connector lease, monotonic fencing, server-bound execution grants, audit, migrations, concurrency/restart/abuse/integration tests | #755 | codex | packages/types, packages/agent, packages/db, apps/gateway | feat/mos-logical-identity-fencing | — | 38K | Requirements MOS-PORT-ID-001, MOS-PORT-LEASE-001, MOS-PORT-FENCE-001..002, MOS-PORT-OBS-001, MOS-PORT-ARCH-001. One Sol/Pi worker; TDD; PR-open STOP; worker must not edit this ledger. |
|
||||
|
||||
261
packages/agent/src/connector-lease.test.ts
Normal file
261
packages/agent/src/connector-lease.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
ConnectorExecutionContext,
|
||||
ConnectorExecutionGrant,
|
||||
ConnectorLease,
|
||||
ConnectorLeaseAuditEvent,
|
||||
ConnectorLeaseStore,
|
||||
FencedConnectorAdapter,
|
||||
LogicalAgentBinding,
|
||||
} from '@mosaicstack/types';
|
||||
import {
|
||||
ConnectorLeaseCoordinator,
|
||||
MAX_CONNECTOR_GRANT_TTL_MS,
|
||||
MAX_CONNECTOR_LEASE_TTL_MS,
|
||||
} from './connector-lease.js';
|
||||
import type { ConnectorLeaseError } from './connector-lease.js';
|
||||
|
||||
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
|
||||
const binding: LogicalAgentBinding = { identity, bindingId: 'operator-chat' };
|
||||
const activeLease: ConnectorLease = {
|
||||
...binding,
|
||||
leaseId: '00000000-0000-4000-8000-000000000001',
|
||||
connectorId: 'connector-a',
|
||||
scopes: ['runtime.send', 'tool.execute'],
|
||||
leaseEpoch: '3',
|
||||
acquiredAt: '2026-07-14T17:00:00.000Z',
|
||||
heartbeatAt: '2026-07-14T17:00:00.000Z',
|
||||
expiresAt: '2026-07-14T17:10:00.000Z',
|
||||
};
|
||||
|
||||
class FakeLeaseStore implements ConnectorLeaseStore {
|
||||
lease: ConnectorLease | null = activeLease;
|
||||
readonly audits: ConnectorLeaseAuditEvent[] = [];
|
||||
|
||||
async acquire(): Promise<ConnectorLease> {
|
||||
if (!this.lease) throw new Error('fixture has no lease');
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async takeover(): Promise<ConnectorLease> {
|
||||
if (!this.lease) throw new Error('fixture has no lease');
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async heartbeat(): Promise<ConnectorLease> {
|
||||
if (!this.lease) throw new Error('fixture has no lease');
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async release(): Promise<void> {}
|
||||
|
||||
async findCurrent(): Promise<ConnectorLease | null> {
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||
this.audits.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConnectorLeaseCoordinator fencing', (): void => {
|
||||
it('validates a server-minted grant immediately before invoking an adapter side effect', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
const grant = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-1',
|
||||
});
|
||||
const execute = vi.fn(async (_input: string, context: ConnectorExecutionContext) => context);
|
||||
const adapter: FencedConnectorAdapter<string, ConnectorExecutionContext> = { execute };
|
||||
|
||||
const context = await coordinator.executeGrant(grant, 'runtime.send', 'hello', adapter);
|
||||
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(context).toMatchObject({
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'connector-a',
|
||||
leaseEpoch: '3',
|
||||
scopes: ['runtime.send'],
|
||||
});
|
||||
});
|
||||
|
||||
it('caps grant expiry to the durable current lease instead of submitted metadata', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
store.lease = { ...activeLease, expiresAt: '2026-07-14T17:01:05.000Z' };
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
|
||||
const grant = await coordinator.issueGrant({
|
||||
lease: { ...activeLease, expiresAt: '2026-07-14T18:00:00.000Z' },
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-durable-expiry',
|
||||
});
|
||||
|
||||
expect(grant.expiresAt).toBe('2026-07-14T17:01:05.000Z');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['forged clone', (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ ...grant })],
|
||||
[
|
||||
'cross-tenant clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
identity: { ...grant.identity, tenantId: 'tenant-b' },
|
||||
}),
|
||||
],
|
||||
[
|
||||
'cross-agent clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
identity: { ...grant.identity, logicalAgentId: 'other-agent' },
|
||||
}),
|
||||
],
|
||||
[
|
||||
'cross-binding clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
bindingId: 'other-binding',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'cross-connector clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
connectorId: 'connector-b',
|
||||
}),
|
||||
],
|
||||
])('denies and audits a %s before adapter invocation', async (_label, forge): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
const grant = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-forged',
|
||||
});
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(
|
||||
coordinator.executeGrant(forge(grant), 'runtime.send', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
expect(store.audits.at(-1)).toMatchObject({
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'forged_grant',
|
||||
correlationId: 'correlation-forged',
|
||||
});
|
||||
});
|
||||
|
||||
it('denies malformed forged grants with sanitized audit metadata', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(
|
||||
coordinator.executeGrant(
|
||||
// @ts-expect-error Deliberately exercise malformed runtime input at the trust boundary.
|
||||
{},
|
||||
'runtime.send',
|
||||
undefined,
|
||||
adapter,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
expect(store.audits).toContainEqual(
|
||||
expect.objectContaining({
|
||||
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
|
||||
bindingId: 'untrusted',
|
||||
connectorId: 'untrusted',
|
||||
correlationId: 'untrusted',
|
||||
reason: 'forged_grant',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects lease and grant TTLs above server-side safety caps', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
expect(
|
||||
() =>
|
||||
new ConnectorLeaseCoordinator(store, {
|
||||
maxLeaseTtlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
|
||||
}),
|
||||
).toThrow(/no greater than/);
|
||||
|
||||
await expect(
|
||||
coordinator.acquire({
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'connector-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
|
||||
correlationId: 'correlation-ttl',
|
||||
}),
|
||||
).rejects.toThrow(/no greater than/);
|
||||
await expect(
|
||||
coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: MAX_CONNECTOR_GRANT_TTL_MS + 1,
|
||||
correlationId: 'correlation-ttl',
|
||||
}),
|
||||
).rejects.toThrow(/no greater than/);
|
||||
});
|
||||
|
||||
it('denies stale epoch, expired grant, and unauthorized scope before side effects', async (): Promise<void> => {
|
||||
let now = new Date('2026-07-14T17:01:00.000Z');
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, { now: (): Date => now });
|
||||
const stale = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-stale',
|
||||
});
|
||||
store.lease = { ...activeLease, leaseEpoch: '4', connectorId: 'connector-b' };
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(
|
||||
coordinator.executeGrant(stale, 'runtime.send', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'stale_epoch' } satisfies Partial<ConnectorLeaseError>);
|
||||
|
||||
store.lease = activeLease;
|
||||
const expiring = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 1_000,
|
||||
correlationId: 'correlation-expired',
|
||||
});
|
||||
now = new Date('2026-07-14T17:01:02.000Z');
|
||||
await expect(
|
||||
coordinator.executeGrant(expiring, 'runtime.send', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'grant_expired' } satisfies Partial<ConnectorLeaseError>);
|
||||
|
||||
now = new Date('2026-07-14T17:01:00.000Z');
|
||||
const scoped = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-scope',
|
||||
});
|
||||
await expect(
|
||||
coordinator.executeGrant(scoped, 'tool.execute', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'scope_denied' } satisfies Partial<ConnectorLeaseError>);
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
381
packages/agent/src/connector-lease.ts
Normal file
381
packages/agent/src/connector-lease.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
normalizeConnectorId,
|
||||
normalizeConnectorScope,
|
||||
normalizeConnectorScopes,
|
||||
normalizeCorrelationId,
|
||||
normalizeLeaseEpoch,
|
||||
normalizeLogicalAgentIdentity,
|
||||
normalizeLogicalBindingId,
|
||||
type AcquireConnectorLeaseInput,
|
||||
type ConnectorExecutionContext,
|
||||
type ConnectorExecutionGrant,
|
||||
type ConnectorLease,
|
||||
type ConnectorLeaseAuditEvent,
|
||||
type ConnectorLeaseRejectReason,
|
||||
type ConnectorLeaseStore,
|
||||
type FencedConnectorAdapter,
|
||||
type HeartbeatConnectorLeaseInput,
|
||||
type IssueConnectorExecutionGrantInput,
|
||||
type LogicalAgentBinding,
|
||||
type ReleaseConnectorLeaseInput,
|
||||
type TakeoverConnectorLeaseInput,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
export const MAX_CONNECTOR_LEASE_TTL_MS = 5 * 60 * 1000;
|
||||
export const MAX_CONNECTOR_GRANT_TTL_MS = 30 * 1000;
|
||||
|
||||
export interface ConnectorLeaseCoordinatorOptions {
|
||||
readonly now?: () => Date;
|
||||
readonly maxLeaseTtlMs?: number;
|
||||
readonly maxGrantTtlMs?: number;
|
||||
}
|
||||
|
||||
export class ConnectorLeaseError extends Error {
|
||||
constructor(
|
||||
readonly code: ConnectorLeaseRejectReason,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = ConnectorLeaseError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-neutral lease coordinator. Durable CAS lives in the store adapter;
|
||||
* grant provenance remains process-local so a restart fails closed and mints
|
||||
* fresh grants from the durable current lease.
|
||||
*/
|
||||
export class ConnectorLeaseCoordinator {
|
||||
private readonly issuedGrants = new WeakSet<object>();
|
||||
private readonly now: () => Date;
|
||||
private readonly maxLeaseTtlMs: number;
|
||||
private readonly maxGrantTtlMs: number;
|
||||
|
||||
constructor(
|
||||
private readonly store: ConnectorLeaseStore,
|
||||
options: ConnectorLeaseCoordinatorOptions = {},
|
||||
) {
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.maxLeaseTtlMs = normalizeTtlLimit(
|
||||
options.maxLeaseTtlMs ?? MAX_CONNECTOR_LEASE_TTL_MS,
|
||||
MAX_CONNECTOR_LEASE_TTL_MS,
|
||||
'lease',
|
||||
);
|
||||
this.maxGrantTtlMs = normalizeTtlLimit(
|
||||
options.maxGrantTtlMs ?? MAX_CONNECTOR_GRANT_TTL_MS,
|
||||
MAX_CONNECTOR_GRANT_TTL_MS,
|
||||
'grant',
|
||||
);
|
||||
}
|
||||
|
||||
async acquire(input: AcquireConnectorLeaseInput): Promise<ConnectorLease> {
|
||||
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
|
||||
const now = this.now();
|
||||
return this.store.acquire({
|
||||
...command,
|
||||
leaseId: randomUUID(),
|
||||
now: now.toISOString(),
|
||||
expiresAt: expiresAt(now, command.ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
async takeover(input: TakeoverConnectorLeaseInput): Promise<ConnectorLease> {
|
||||
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
|
||||
const now = this.now();
|
||||
return this.store.takeover({
|
||||
...command,
|
||||
expectedEpoch: normalizeLeaseEpoch(input.expectedEpoch),
|
||||
leaseId: randomUUID(),
|
||||
now: now.toISOString(),
|
||||
expiresAt: expiresAt(now, command.ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
async heartbeat(input: HeartbeatConnectorLeaseInput): Promise<ConnectorLease> {
|
||||
const now = this.now();
|
||||
const lease = normalizeConnectorLease(input.lease);
|
||||
const ttlMs = normalizeTtl(input.ttlMs, this.maxLeaseTtlMs, 'lease');
|
||||
return this.store.heartbeat({
|
||||
lease,
|
||||
ttlMs,
|
||||
correlationId: normalizeCorrelationId(input.correlationId),
|
||||
now: now.toISOString(),
|
||||
expiresAt: expiresAt(now, ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
async release(input: ReleaseConnectorLeaseInput): Promise<void> {
|
||||
await this.store.release({
|
||||
lease: normalizeConnectorLease(input.lease),
|
||||
correlationId: normalizeCorrelationId(input.correlationId),
|
||||
now: this.now().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async current(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
|
||||
return this.store.findCurrent(normalizeBinding(binding));
|
||||
}
|
||||
|
||||
async issueGrant(input: IssueConnectorExecutionGrantInput): Promise<ConnectorExecutionGrant> {
|
||||
const now = this.now();
|
||||
const lease = normalizeConnectorLease(input.lease);
|
||||
const scopes = normalizeConnectorScopes(input.scopes);
|
||||
const correlationId = normalizeCorrelationId(input.correlationId);
|
||||
const ttlMs = normalizeTtl(input.ttlMs, this.maxGrantTtlMs, 'grant');
|
||||
const current = await this.store.findCurrent(lease);
|
||||
await this.assertCurrentLease(current, lease, now, correlationId);
|
||||
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||
if (!isScopeSubset(scopes, lease.scopes) || !isScopeSubset(scopes, current.scopes)) {
|
||||
await this.reject(lease, correlationId, now, 'scope_denied');
|
||||
}
|
||||
const requestedExpiry = new Date(now.getTime() + ttlMs);
|
||||
const leaseExpiry = new Date(current.expiresAt);
|
||||
const grantExpiry = requestedExpiry < leaseExpiry ? requestedExpiry : leaseExpiry;
|
||||
const grant: ConnectorExecutionGrant = Object.freeze({
|
||||
identity: current.identity,
|
||||
bindingId: current.bindingId,
|
||||
leaseId: current.leaseId,
|
||||
connectorId: current.connectorId,
|
||||
scopes,
|
||||
leaseEpoch: current.leaseEpoch,
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: grantExpiry.toISOString(),
|
||||
correlationId,
|
||||
});
|
||||
this.issuedGrants.add(grant);
|
||||
return grant;
|
||||
}
|
||||
|
||||
async executeGrant<TInput, TOutput>(
|
||||
grant: ConnectorExecutionGrant,
|
||||
requiredScope: string,
|
||||
input: TInput,
|
||||
adapter: FencedConnectorAdapter<TInput, TOutput>,
|
||||
): Promise<TOutput> {
|
||||
const now = this.now();
|
||||
const normalizedScope = normalizeConnectorScope(requiredScope);
|
||||
if (!this.issuedGrants.has(grant)) {
|
||||
await this.rejectForgedGrant(grant, now);
|
||||
}
|
||||
if (new Date(grant.expiresAt) <= now) {
|
||||
await this.rejectGrant(grant, now, 'grant_expired');
|
||||
}
|
||||
const current = await this.store.findCurrent(normalizeBinding(grant));
|
||||
await this.assertCurrentLease(current, grant, now, grant.correlationId);
|
||||
if (!grant.scopes.includes(normalizedScope) || !current?.scopes.includes(normalizedScope)) {
|
||||
await this.rejectGrant(grant, now, 'scope_denied');
|
||||
}
|
||||
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||
const context: ConnectorExecutionContext = Object.freeze({
|
||||
identity: current.identity,
|
||||
bindingId: current.bindingId,
|
||||
leaseId: current.leaseId,
|
||||
connectorId: current.connectorId,
|
||||
scopes: Object.freeze([...grant.scopes]),
|
||||
leaseEpoch: current.leaseEpoch,
|
||||
correlationId: grant.correlationId,
|
||||
grantExpiresAt: grant.expiresAt,
|
||||
});
|
||||
return adapter.execute(input, context);
|
||||
}
|
||||
|
||||
private async assertCurrentLease(
|
||||
current: ConnectorLease | null,
|
||||
authority: ConnectorLease | ConnectorExecutionGrant,
|
||||
now: Date,
|
||||
correlationId: string,
|
||||
): Promise<void> {
|
||||
if (!current) await this.reject(authority, correlationId, now, 'lease_missing');
|
||||
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||
if (current.releasedAt) await this.reject(authority, correlationId, now, 'lease_released');
|
||||
if (new Date(current.expiresAt) <= now) {
|
||||
await this.store.recordAudit(auditEvent(current, correlationId, now, 'expiry', 'succeeded'));
|
||||
await this.reject(authority, correlationId, now, 'lease_expired');
|
||||
}
|
||||
if (current.leaseEpoch !== authority.leaseEpoch) {
|
||||
await this.reject(authority, correlationId, now, 'stale_epoch');
|
||||
}
|
||||
if (current.leaseId !== authority.leaseId || current.connectorId !== authority.connectorId) {
|
||||
await this.reject(authority, correlationId, now, 'connector_mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
private async rejectGrant(
|
||||
grant: ConnectorExecutionGrant,
|
||||
now: Date,
|
||||
reason: ConnectorLeaseRejectReason,
|
||||
): Promise<never> {
|
||||
return this.reject(grant, grant.correlationId, now, reason);
|
||||
}
|
||||
|
||||
private async rejectForgedGrant(grant: unknown, now: Date): Promise<never> {
|
||||
const event = safeForgedGrantAudit(grant, now);
|
||||
await this.store.recordAudit(event);
|
||||
throw new ConnectorLeaseError('forged_grant', safeReasonMessage('forged_grant'));
|
||||
}
|
||||
|
||||
private async reject(
|
||||
authority: LogicalAgentBinding & {
|
||||
readonly connectorId: string;
|
||||
readonly leaseId?: string;
|
||||
readonly leaseEpoch?: string;
|
||||
},
|
||||
correlationId: string,
|
||||
now: Date,
|
||||
reason: ConnectorLeaseRejectReason,
|
||||
): Promise<never> {
|
||||
await this.store.recordAudit(
|
||||
auditEvent(authority, correlationId, now, 'reject', 'denied', reason),
|
||||
);
|
||||
throw new ConnectorLeaseError(reason, safeReasonMessage(reason));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAcquireInput(
|
||||
input: AcquireConnectorLeaseInput,
|
||||
maxLeaseTtlMs: number,
|
||||
): AcquireConnectorLeaseInput {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity(input.identity),
|
||||
bindingId: normalizeLogicalBindingId(input.bindingId),
|
||||
connectorId: normalizeConnectorId(input.connectorId),
|
||||
scopes: normalizeConnectorScopes(input.scopes),
|
||||
ttlMs: normalizeTtl(input.ttlMs, maxLeaseTtlMs, 'lease'),
|
||||
correlationId: normalizeCorrelationId(input.correlationId),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBinding(input: LogicalAgentBinding): LogicalAgentBinding {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity(input.identity),
|
||||
bindingId: normalizeLogicalBindingId(input.bindingId),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeConnectorLease(lease: ConnectorLease): ConnectorLease {
|
||||
const binding = normalizeBinding(lease);
|
||||
return Object.freeze({
|
||||
...binding,
|
||||
leaseId: lease.leaseId,
|
||||
connectorId: normalizeConnectorId(lease.connectorId),
|
||||
scopes: normalizeConnectorScopes(lease.scopes),
|
||||
leaseEpoch: normalizeLeaseEpoch(lease.leaseEpoch),
|
||||
acquiredAt: normalizeTimestamp(lease.acquiredAt, 'lease acquisition'),
|
||||
heartbeatAt: normalizeTimestamp(lease.heartbeatAt, 'lease heartbeat'),
|
||||
expiresAt: normalizeTimestamp(lease.expiresAt, 'lease expiry'),
|
||||
...(lease.releasedAt
|
||||
? { releasedAt: normalizeTimestamp(lease.releasedAt, 'lease release') }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTtl(ttlMs: number, maximum: number, kind: 'lease' | 'grant'): number {
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > maximum) {
|
||||
throw new Error(
|
||||
`Connector ${kind} TTL must be a positive safe integer no greater than ${maximum}ms`,
|
||||
);
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
function normalizeTtlLimit(ttlMs: number, hardMaximum: number, kind: 'lease' | 'grant'): number {
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > hardMaximum) {
|
||||
throw new Error(
|
||||
`Maximum connector ${kind} TTL must be a positive safe integer no greater than ${hardMaximum}ms`,
|
||||
);
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: string, label: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) throw new Error(`${label} timestamp is invalid`);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function expiresAt(now: Date, ttlMs: number): string {
|
||||
const expiry = new Date(now.getTime() + ttlMs);
|
||||
if (Number.isNaN(expiry.getTime())) throw new Error('Connector lease TTL exceeds date range');
|
||||
return expiry.toISOString();
|
||||
}
|
||||
|
||||
function isScopeSubset(requested: readonly string[], allowed: readonly string[]): boolean {
|
||||
return requested.every((scope) => allowed.includes(scope));
|
||||
}
|
||||
|
||||
function auditEvent(
|
||||
authority: LogicalAgentBinding & {
|
||||
readonly connectorId: string;
|
||||
readonly leaseId?: string;
|
||||
readonly leaseEpoch?: string;
|
||||
},
|
||||
correlationId: string,
|
||||
now: Date,
|
||||
event: ConnectorLeaseAuditEvent['event'],
|
||||
outcome: ConnectorLeaseAuditEvent['outcome'],
|
||||
reason?: ConnectorLeaseRejectReason,
|
||||
): ConnectorLeaseAuditEvent {
|
||||
return {
|
||||
identity: authority.identity,
|
||||
bindingId: authority.bindingId,
|
||||
connectorId: authority.connectorId,
|
||||
correlationId,
|
||||
occurredAt: now.toISOString(),
|
||||
event,
|
||||
outcome,
|
||||
...(authority.leaseId ? { leaseId: authority.leaseId } : {}),
|
||||
...(authority.leaseEpoch ? { leaseEpoch: authority.leaseEpoch } : {}),
|
||||
...(reason ? { reason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function safeForgedGrantAudit(grant: unknown, now: Date): ConnectorLeaseAuditEvent {
|
||||
const fallback: ConnectorLeaseAuditEvent = {
|
||||
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
|
||||
bindingId: 'untrusted',
|
||||
connectorId: 'untrusted',
|
||||
correlationId: 'untrusted',
|
||||
occurredAt: now.toISOString(),
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'forged_grant',
|
||||
};
|
||||
if (typeof grant !== 'object' || grant === null || !('identity' in grant)) return fallback;
|
||||
const identity = grant.identity;
|
||||
if (typeof identity !== 'object' || identity === null) return fallback;
|
||||
if (!('tenantId' in identity) || !('logicalAgentId' in identity)) return fallback;
|
||||
if (!('bindingId' in grant) || !('connectorId' in grant) || !('correlationId' in grant)) {
|
||||
return fallback;
|
||||
}
|
||||
if (
|
||||
typeof identity.tenantId !== 'string' ||
|
||||
typeof identity.logicalAgentId !== 'string' ||
|
||||
typeof grant.bindingId !== 'string' ||
|
||||
typeof grant.connectorId !== 'string' ||
|
||||
typeof grant.correlationId !== 'string'
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity({
|
||||
tenantId: identity.tenantId,
|
||||
logicalAgentId: identity.logicalAgentId,
|
||||
}),
|
||||
bindingId: normalizeLogicalBindingId(grant.bindingId),
|
||||
connectorId: normalizeConnectorId(grant.connectorId),
|
||||
correlationId: normalizeCorrelationId(grant.correlationId),
|
||||
occurredAt: now.toISOString(),
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'forged_grant',
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function safeReasonMessage(reason: ConnectorLeaseRejectReason): string {
|
||||
return `Connector authority denied: ${reason}`;
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from './tmux-fleet-runtime-provider.js';
|
||||
export * from './hermes-runtime-provider.js';
|
||||
export * from './matrix-native-runtime-provider.js';
|
||||
export * from './durable-session.js';
|
||||
export * from './connector-lease.js';
|
||||
|
||||
36
packages/db/drizzle/0016_salty_morlocks.sql
Normal file
36
packages/db/drizzle/0016_salty_morlocks.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE "connector_lease_audit_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" text NOT NULL,
|
||||
"logical_agent_id" text NOT NULL,
|
||||
"binding_id" text NOT NULL,
|
||||
"connector_id" text NOT NULL,
|
||||
"lease_id" uuid,
|
||||
"lease_epoch" bigint,
|
||||
"event" text NOT NULL,
|
||||
"outcome" text NOT NULL,
|
||||
"reason" text,
|
||||
"correlation_id" text NOT NULL,
|
||||
"occurred_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "logical_agent_connector_leases" (
|
||||
"lease_id" uuid PRIMARY KEY NOT NULL,
|
||||
"tenant_id" text NOT NULL,
|
||||
"logical_agent_id" text NOT NULL,
|
||||
"binding_id" text NOT NULL,
|
||||
"connector_id" text NOT NULL,
|
||||
"scopes" jsonb NOT NULL,
|
||||
"lease_epoch" bigint NOT NULL,
|
||||
"acquired_at" timestamp with time zone NOT NULL,
|
||||
"heartbeat_at" timestamp with time zone NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"released_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "connector_lease_audit_binding_occurred_idx" ON "connector_lease_audit_log" USING btree ("tenant_id","logical_agent_id","binding_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE INDEX "connector_lease_audit_correlation_idx" ON "connector_lease_audit_log" USING btree ("correlation_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "logical_agent_connector_lease_binding_idx" ON "logical_agent_connector_leases" USING btree ("tenant_id","logical_agent_id","binding_id");--> statement-breakpoint
|
||||
CREATE INDEX "logical_agent_connector_lease_expiry_idx" ON "logical_agent_connector_leases" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "logical_agent_connector_lease_connector_idx" ON "logical_agent_connector_leases" USING btree ("connector_id");
|
||||
4530
packages/db/drizzle/meta/0016_snapshot.json
Normal file
4530
packages/db/drizzle/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
||||
"when": 1783942610000,
|
||||
"tag": "0015_interaction_checkpoint_payload_digest",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1784050648841,
|
||||
"tag": "0016_salty_morlocks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
uniqueIndex,
|
||||
real,
|
||||
integer,
|
||||
bigint,
|
||||
customType,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
@@ -487,6 +488,68 @@ export const agentLogs = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Logical agent connector authority ──────────────────────────────────────
|
||||
// One durable row is the current authority for a tenant/logical-agent/binding.
|
||||
// Runtime-native session identifiers never enter these core tables.
|
||||
|
||||
export const logicalAgentConnectorLeases = pgTable(
|
||||
'logical_agent_connector_leases',
|
||||
{
|
||||
leaseId: uuid('lease_id').primaryKey(),
|
||||
tenantId: text('tenant_id').notNull(),
|
||||
logicalAgentId: text('logical_agent_id').notNull(),
|
||||
bindingId: text('binding_id').notNull(),
|
||||
connectorId: text('connector_id').notNull(),
|
||||
scopes: jsonb('scopes').notNull().$type<string[]>(),
|
||||
leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }).notNull(),
|
||||
acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull(),
|
||||
heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }).notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
releasedAt: timestamp('released_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('logical_agent_connector_lease_binding_idx').on(
|
||||
t.tenantId,
|
||||
t.logicalAgentId,
|
||||
t.bindingId,
|
||||
),
|
||||
index('logical_agent_connector_lease_expiry_idx').on(t.expiresAt),
|
||||
index('logical_agent_connector_lease_connector_idx').on(t.connectorId),
|
||||
],
|
||||
);
|
||||
|
||||
/** Append-only, credential-safe lease lifecycle and fencing denial metadata. */
|
||||
export const connectorLeaseAuditLog = pgTable(
|
||||
'connector_lease_audit_log',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
tenantId: text('tenant_id').notNull(),
|
||||
logicalAgentId: text('logical_agent_id').notNull(),
|
||||
bindingId: text('binding_id').notNull(),
|
||||
connectorId: text('connector_id').notNull(),
|
||||
leaseId: uuid('lease_id'),
|
||||
leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }),
|
||||
event: text('event', {
|
||||
enum: ['acquire', 'renew', 'takeover', 'reject', 'release', 'expiry'],
|
||||
}).notNull(),
|
||||
outcome: text('outcome', { enum: ['succeeded', 'denied'] }).notNull(),
|
||||
reason: text('reason'),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('connector_lease_audit_binding_occurred_idx').on(
|
||||
t.tenantId,
|
||||
t.logicalAgentId,
|
||||
t.bindingId,
|
||||
t.occurredAt.desc(),
|
||||
),
|
||||
index('connector_lease_audit_correlation_idx').on(t.correlationId),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Tess durable session state ─────────────────────────────────────────────
|
||||
// PostgreSQL is canonical for restart-safe Tess session recovery. The state
|
||||
// machine lives in @mosaicstack/agent; these records are its durable adapter.
|
||||
|
||||
59
packages/types/src/agent/connector-lease.dto.spec.ts
Normal file
59
packages/types/src/agent/connector-lease.dto.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizeConnectorId,
|
||||
normalizeConnectorScopes,
|
||||
normalizeLeaseEpoch,
|
||||
normalizeLogicalAgentIdentity,
|
||||
normalizeLogicalBindingId,
|
||||
type ConnectorExecutionContext,
|
||||
type LogicalAgentIdentity,
|
||||
} from './connector-lease.dto.js';
|
||||
|
||||
describe('logical agent connector lease contract', (): void => {
|
||||
it('normalizes a runtime-neutral logical identity and binding vocabulary', (): void => {
|
||||
const identity = normalizeLogicalAgentIdentity({
|
||||
tenantId: ' tenant-01 ',
|
||||
logicalAgentId: ' MOS.Primary ',
|
||||
});
|
||||
|
||||
expect(identity).toEqual({ tenantId: 'tenant-01', logicalAgentId: 'mos.primary' });
|
||||
expect(normalizeLogicalBindingId(' Discord:Operations ')).toBe('discord:operations');
|
||||
expect(normalizeConnectorId(' PI.Worker-01 ')).toBe('pi.worker-01');
|
||||
expect(Object.isFrozen(identity)).toBe(true);
|
||||
expect(Object.keys(identity).sort()).toEqual(['logicalAgentId', 'tenantId']);
|
||||
});
|
||||
|
||||
it('canonicalizes scopes and decimal fencing epochs', (): void => {
|
||||
expect(normalizeConnectorScopes([' Runtime.Send ', 'tool.execute', 'runtime.send'])).toEqual([
|
||||
'runtime.send',
|
||||
'tool.execute',
|
||||
]);
|
||||
expect(normalizeLeaseEpoch('00042')).toBe('42');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', 'mos'],
|
||||
['tenant', ''],
|
||||
['tenant', 'claude session/123'],
|
||||
])('rejects ambiguous identity values tenant=%j agent=%j', (tenantId, logicalAgentId): void => {
|
||||
expect(() => normalizeLogicalAgentIdentity({ tenantId, logicalAgentId })).toThrow();
|
||||
});
|
||||
|
||||
it('defines an adapter context without harness-native identity fields', (): void => {
|
||||
const identity: LogicalAgentIdentity = { tenantId: 'tenant-01', logicalAgentId: 'mos' };
|
||||
const context: ConnectorExecutionContext = {
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'connector-a',
|
||||
leaseId: '00000000-0000-4000-8000-000000000001',
|
||||
leaseEpoch: '7',
|
||||
scopes: ['runtime.send'],
|
||||
correlationId: 'correlation-1',
|
||||
grantExpiresAt: '2026-07-14T18:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(context).not.toHaveProperty('sessionId');
|
||||
expect(context).not.toHaveProperty('tmuxSession');
|
||||
expect(context).not.toHaveProperty('providerSessionId');
|
||||
});
|
||||
});
|
||||
199
packages/types/src/agent/connector-lease.dto.ts
Normal file
199
packages/types/src/agent/connector-lease.dto.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9._:@-]{0,127}$/;
|
||||
const TENANT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/;
|
||||
const SCOPE_PATTERN = /^[a-z][a-z0-9._:-]{0,127}$/;
|
||||
|
||||
/** Stable Mosaic identity. It intentionally contains no runtime/provider session identifier. */
|
||||
export interface LogicalAgentIdentity {
|
||||
readonly tenantId: string;
|
||||
readonly logicalAgentId: string;
|
||||
}
|
||||
|
||||
export interface LogicalAgentBinding {
|
||||
readonly identity: LogicalAgentIdentity;
|
||||
readonly bindingId: string;
|
||||
}
|
||||
|
||||
export interface ConnectorLease extends LogicalAgentBinding {
|
||||
readonly leaseId: string;
|
||||
readonly connectorId: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly leaseEpoch: string;
|
||||
readonly acquiredAt: string;
|
||||
readonly heartbeatAt: string;
|
||||
readonly expiresAt: string;
|
||||
readonly releasedAt?: string;
|
||||
}
|
||||
|
||||
export interface AcquireConnectorLeaseInput {
|
||||
readonly identity: LogicalAgentIdentity;
|
||||
readonly bindingId: string;
|
||||
readonly connectorId: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly ttlMs: number;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface TakeoverConnectorLeaseInput extends AcquireConnectorLeaseInput {
|
||||
readonly expectedEpoch: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatConnectorLeaseInput {
|
||||
readonly lease: ConnectorLease;
|
||||
readonly ttlMs: number;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface ReleaseConnectorLeaseInput {
|
||||
readonly lease: ConnectorLease;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface IssueConnectorExecutionGrantInput {
|
||||
readonly lease: ConnectorLease;
|
||||
readonly scopes: readonly string[];
|
||||
readonly ttlMs: number;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
/** Internal server grant. Object provenance is checked in addition to these fields. */
|
||||
export interface ConnectorExecutionGrant extends LogicalAgentBinding {
|
||||
readonly leaseId: string;
|
||||
readonly connectorId: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly leaseEpoch: string;
|
||||
readonly issuedAt: string;
|
||||
readonly expiresAt: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
/** Normalized context passed to an adapter only after current-lease validation. */
|
||||
export interface ConnectorExecutionContext extends LogicalAgentBinding {
|
||||
readonly leaseId: string;
|
||||
readonly connectorId: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly leaseEpoch: string;
|
||||
readonly correlationId: string;
|
||||
readonly grantExpiresAt: string;
|
||||
}
|
||||
|
||||
export interface FencedConnectorAdapter<TInput, TOutput> {
|
||||
execute(input: TInput, context: ConnectorExecutionContext): Promise<TOutput>;
|
||||
}
|
||||
|
||||
export type ConnectorLeaseAuditEventType =
|
||||
| 'acquire'
|
||||
| 'renew'
|
||||
| 'takeover'
|
||||
| 'reject'
|
||||
| 'release'
|
||||
| 'expiry';
|
||||
export type ConnectorLeaseAuditOutcome = 'succeeded' | 'denied';
|
||||
export type ConnectorLeaseRejectReason =
|
||||
| 'policy_denied'
|
||||
| 'lease_held'
|
||||
| 'takeover_required'
|
||||
| 'cas_mismatch'
|
||||
| 'lease_missing'
|
||||
| 'lease_released'
|
||||
| 'lease_expired'
|
||||
| 'stale_epoch'
|
||||
| 'connector_mismatch'
|
||||
| 'scope_denied'
|
||||
| 'forged_grant'
|
||||
| 'grant_expired';
|
||||
|
||||
/** Credential-safe metadata only: no grant object, scope set, payload, token, or approval ref. */
|
||||
export interface ConnectorLeaseAuditEvent extends LogicalAgentBinding {
|
||||
readonly event: ConnectorLeaseAuditEventType;
|
||||
readonly outcome: ConnectorLeaseAuditOutcome;
|
||||
readonly connectorId: string;
|
||||
readonly correlationId: string;
|
||||
readonly occurredAt: string;
|
||||
readonly leaseId?: string;
|
||||
readonly leaseEpoch?: string;
|
||||
readonly reason?: ConnectorLeaseRejectReason;
|
||||
}
|
||||
|
||||
export interface ConnectorLeaseAcquireMutation extends AcquireConnectorLeaseInput {
|
||||
readonly leaseId: string;
|
||||
readonly now: string;
|
||||
readonly expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ConnectorLeaseTakeoverMutation extends TakeoverConnectorLeaseInput {
|
||||
readonly leaseId: string;
|
||||
readonly now: string;
|
||||
readonly expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ConnectorLeaseHeartbeatMutation extends HeartbeatConnectorLeaseInput {
|
||||
readonly now: string;
|
||||
readonly expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ConnectorLeaseReleaseMutation extends ReleaseConnectorLeaseInput {
|
||||
readonly now: string;
|
||||
}
|
||||
|
||||
export interface ConnectorLeaseStore {
|
||||
acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease>;
|
||||
takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease>;
|
||||
heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease>;
|
||||
release(input: ConnectorLeaseReleaseMutation): Promise<void>;
|
||||
findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null>;
|
||||
recordAudit(event: ConnectorLeaseAuditEvent): Promise<void>;
|
||||
}
|
||||
|
||||
export function normalizeLogicalAgentIdentity(input: LogicalAgentIdentity): LogicalAgentIdentity {
|
||||
const tenantId = requiredIdentifier(input.tenantId, 'tenant ID', TENANT_PATTERN, false);
|
||||
const logicalAgentId = requiredIdentifier(
|
||||
input.logicalAgentId,
|
||||
'logical agent ID',
|
||||
ID_PATTERN,
|
||||
true,
|
||||
);
|
||||
return Object.freeze({ tenantId, logicalAgentId });
|
||||
}
|
||||
|
||||
export function normalizeLogicalBindingId(value: string): string {
|
||||
return requiredIdentifier(value, 'logical binding ID', ID_PATTERN, true);
|
||||
}
|
||||
|
||||
export function normalizeConnectorId(value: string): string {
|
||||
return requiredIdentifier(value, 'connector ID', ID_PATTERN, true);
|
||||
}
|
||||
|
||||
export function normalizeCorrelationId(value: string): string {
|
||||
return requiredIdentifier(value, 'correlation ID', TENANT_PATTERN, false);
|
||||
}
|
||||
|
||||
export function normalizeConnectorScope(value: string): string {
|
||||
return requiredIdentifier(value, 'connector scope', SCOPE_PATTERN, true);
|
||||
}
|
||||
|
||||
export function normalizeConnectorScopes(values: readonly string[]): readonly string[] {
|
||||
if (values.length === 0) throw new Error('At least one connector scope is required');
|
||||
return Object.freeze(Array.from(new Set(values.map(normalizeConnectorScope))).sort());
|
||||
}
|
||||
|
||||
export function normalizeLeaseEpoch(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) throw new Error('Lease epoch must be a positive decimal integer');
|
||||
const epoch = BigInt(trimmed);
|
||||
if (epoch < 1n) throw new Error('Lease epoch must be a positive decimal integer');
|
||||
return epoch.toString(10);
|
||||
}
|
||||
|
||||
function requiredIdentifier(
|
||||
value: string,
|
||||
label: string,
|
||||
pattern: RegExp,
|
||||
lowerCase: boolean,
|
||||
): string {
|
||||
const trimmed = value.trim();
|
||||
const normalized = lowerCase ? trimmed.toLowerCase() : trimmed;
|
||||
if (!pattern.test(normalized)) {
|
||||
throw new Error(`${label} has an invalid normalized format`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export interface AgentSessionHandle {
|
||||
}
|
||||
|
||||
export * from './agent-runtime-provider.js';
|
||||
export * from './connector-lease.dto.js';
|
||||
|
||||
Reference in New Issue
Block a user