Compare commits
5 Commits
feat/mos-l
...
feat/758-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c41adad1c | ||
|
|
552f99fa0a | ||
|
|
9233019cff | ||
|
|
088d91eb8d | ||
|
|
b8f508038e |
@@ -21,12 +21,6 @@ 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,
|
||||
@@ -52,13 +46,6 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
||||
SkillLoaderService,
|
||||
DurableSessionRepository,
|
||||
DurableSessionService,
|
||||
ConnectorLeaseRepository,
|
||||
DenyConnectorLeasePolicy,
|
||||
{
|
||||
provide: CONNECTOR_LEASE_POLICY,
|
||||
useExisting: DenyConnectorLeasePolicy,
|
||||
},
|
||||
ConnectorLeaseService,
|
||||
{
|
||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
useFactory: createGatewayRuntimeProviderRegistry,
|
||||
@@ -91,7 +78,6 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
||||
SkillLoaderService,
|
||||
DurableSessionService,
|
||||
RuntimeProviderService,
|
||||
ConnectorLeaseService,
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,354 +0,0 @@
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
31
docs/PRD.md
31
docs/PRD.md
@@ -284,37 +284,6 @@ Use TDD for remote-ingress routing and permission boundaries. Required evidence
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -56,5 +56,3 @@
|
||||
- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies.
|
||||
- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754)
|
||||
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)
|
||||
- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md)
|
||||
- [M1 connector lease operations](guides/mos-connector-lease-operations.md)
|
||||
|
||||
@@ -52,20 +52,20 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
> the repository quality gates, independent code and security review, terminal-green CI, and
|
||||
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
||||
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| FCM-M0-001 | in-progress | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | M0 exit: approved docs; every shipped example/profile/service preset classified; docs-only PR |
|
||||
| FCM-M1-001 | not-started | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | codex | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | No lifecycle, remote, connector, secret, channel, or gateway work |
|
||||
| FCM-M1-002 | not-started | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | codex | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Validator is certificate-only; merge-gate remains sole merge authority |
|
||||
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
|
||||
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
|
||||
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
|
||||
| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only |
|
||||
| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting |
|
||||
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
|
||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
|
||||
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
|
||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
|
||||
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
|
||||
| FCM-M1-002 | in-progress | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Started 2026-07-14 from `aa5b43b`; one shared resolver only; validator certificate-only; merge-gate sole merge authority |
|
||||
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
|
||||
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
|
||||
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
|
||||
| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only |
|
||||
| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting |
|
||||
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
|
||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
|
||||
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
|
||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
|
||||
|
||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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.
|
||||
54
docs/fleet/how-to/customize-roles.md
Normal file
54
docs/fleet/how-to/customize-roles.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Customize Fleet Roles
|
||||
|
||||
Mosaic resolves persona contracts through two layers:
|
||||
|
||||
1. `fleet/roles/<canonical-class>.md` — seeded baseline contract.
|
||||
2. `fleet/roles.local/<canonical-class>.md` — operator override or custom role; this layer wins.
|
||||
|
||||
The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation,
|
||||
and launch-time persona injection.
|
||||
|
||||
## Override a baseline role
|
||||
|
||||
Create a readable Markdown contract under `roles.local` with the canonical filename and class marker:
|
||||
|
||||
```markdown
|
||||
# Code — local role definition
|
||||
|
||||
The local code role (`class: code`) follows the operator's repository conventions.
|
||||
```
|
||||
|
||||
Save it as `fleet/roles.local/code.md`. Do not edit generated or seeded baseline assets when the goal
|
||||
is a durable local customization.
|
||||
|
||||
Legacy aliases canonicalize before lookup. Therefore `roles.local/implementer.md` does not override
|
||||
`code`; use `roles.local/code.md`. See [Legacy Fleet Class Aliases](../migration/legacy-class-aliases.md).
|
||||
|
||||
## Add a custom class
|
||||
|
||||
A custom class remains supported when a readable contract exists for the exact identifier:
|
||||
|
||||
```markdown
|
||||
# Release notes — local role definition
|
||||
|
||||
The release-notes role (`class: release-notes`) prepares operator-reviewed release copy.
|
||||
```
|
||||
|
||||
Save it as `fleet/roles.local/release-notes.md`, then reference `class: release-notes` and a matching
|
||||
`tool_policy: release-notes` in roster v2. Adding only a `LIBRARY.md` row is insufficient.
|
||||
|
||||
Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom
|
||||
contracts. `agents[].alias`, Tess, and Ultron are display names and cannot select a class.
|
||||
|
||||
## Validation and authority boundaries
|
||||
|
||||
Semantic validation reads the winning contract and rejects missing, unreadable, or empty files.
|
||||
Protected authority is derived from canonical class metadata in code, never from role prose. A custom
|
||||
contract cannot claim merge, validation-certificate, orchestration, lease, or interaction authority.
|
||||
|
||||
Roster v2 also fails closed when a protected class and tool policy do not match after canonicalization,
|
||||
or when an unprotected class claims a protected tool policy. The legacy `operator-interaction` policy
|
||||
canonicalizes to `interaction`.
|
||||
|
||||
Role customization does not issue leases, store validation certificates, mutate credentials, or
|
||||
change lifecycle state.
|
||||
40
docs/fleet/migration/legacy-class-aliases.md
Normal file
40
docs/fleet/migration/legacy-class-aliases.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Legacy Fleet Class Aliases
|
||||
|
||||
Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy
|
||||
class names and converts them to canonical classes before persona lookup:
|
||||
|
||||
| Legacy value | Canonical value | Migration action |
|
||||
| ---------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `implementer` | `code` | Replace class and tool-policy references with `code`. |
|
||||
| `reviewer` | `review` | Replace class and tool-policy references with `review`. |
|
||||
| `operator-interaction` | `interaction` | Replace class and roster-v2 tool-policy references with `interaction`. The legacy service artifact remains compatible. |
|
||||
|
||||
Alias support preserves existing inputs while provisioning and typed semantic output use canonical
|
||||
identities. Requested and canonical class values remain separately observable during semantic
|
||||
validation.
|
||||
|
||||
## Lookup and override behavior
|
||||
|
||||
Canonicalization precedes baseline and `roles.local` lookup. A legacy-named override such as
|
||||
`roles.local/implementer.md` is not a separate authority and is not selected for an `implementer`
|
||||
request. Customize the canonical role instead, for example `roles.local/code.md`.
|
||||
|
||||
The compatibility file `operator-interaction.md` remains shipped, but `interaction` is the canonical
|
||||
role class. Tess is an example display name only.
|
||||
|
||||
## Unresolved and custom classes
|
||||
|
||||
No names are inferred from historical usage, instance names, or similar wording. `worker`, `analyst`,
|
||||
`canary`, Tess, and Ultron are not aliases. An otherwise unknown class is accepted only if the shared
|
||||
resolver can read an actual baseline or `roles.local` contract for that exact class. A `LIBRARY.md`
|
||||
row without a readable contract fails semantic validation.
|
||||
|
||||
Custom classes receive no protected authority implicitly. Protected class/tool-policy mismatches
|
||||
fail closed.
|
||||
|
||||
## Retirement guidance
|
||||
|
||||
New configuration should emit canonical values. Existing inputs may use the three aliases during the
|
||||
compatibility period, but operators should migrate class and tool-policy fields together. Do not
|
||||
create new legacy-named role overrides; move their intended content to the canonical filename and
|
||||
validate the roster/profile before removing the old artifact.
|
||||
45
docs/fleet/reference/role-classes.md
Normal file
45
docs/fleet/reference/role-classes.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Fleet Role Classes and Authority
|
||||
|
||||
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
|
||||
canonical class before consulting the baseline `fleet/roles/` and operator `fleet/roles.local/`
|
||||
layers. A readable role contract is required; an index entry alone is not semantic success.
|
||||
|
||||
## Canonicalization
|
||||
|
||||
Only these legacy class aliases are recognized:
|
||||
|
||||
| Requested class | Canonical class |
|
||||
| ---------------------- | --------------- |
|
||||
| `implementer` | `code` |
|
||||
| `reviewer` | `review` |
|
||||
| `operator-interaction` | `interaction` |
|
||||
|
||||
No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only
|
||||
when an operator supplies a readable contract for that exact class. Tess and Ultron are instance
|
||||
names, not classes. `agents[].alias` is display-only and cannot grant authority.
|
||||
|
||||
Canonicalization happens before role lookup. For example, requesting `implementer` resolves
|
||||
`code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical
|
||||
`roles.local/code.md` still overrides the baseline `roles/code.md` contract.
|
||||
|
||||
## Protected authority
|
||||
|
||||
Protected authority is immutable metadata derived only from canonical class. Role prose, instance
|
||||
name, display alias, tool policy, runtime, and custom role files cannot grant it.
|
||||
|
||||
| Canonical class | Granted authority | Explicit limits |
|
||||
| ----------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `merge-gate` | Sole approve-to-land and merge authority | No authority is inferred by similarly named custom roles or policies. |
|
||||
| `validator` | May issue a validation certificate | Cannot approve-to-land or merge. |
|
||||
| `orchestrator` | May orchestrate, manage topology, and issue leases | Cannot approve-to-land or merge. |
|
||||
| `team-leader` | May use orchestrator-leased capacity | Cannot issue leases or mutate roster, configuration, credentials, or merge state. |
|
||||
| `interaction` | Request and status surface | Cannot orchestrate, issue leases, mutate roster/configuration, or merge. |
|
||||
| all other classes | No protected authority implicitly | Custom contracts do not acquire protected powers from prose. |
|
||||
|
||||
Roster-v2 semantic validation requires a protected class and its canonical tool policy to match. It
|
||||
also rejects an unprotected class paired with a protected tool policy. The legacy tool-policy name
|
||||
`operator-interaction` canonicalizes to `interaction`.
|
||||
|
||||
This mapping describes authority metadata only. Lease issuance, validation-certificate storage or
|
||||
workflow, lifecycle reconciliation, credentials, roster mutation, and merge execution are outside
|
||||
this resolver contract.
|
||||
@@ -81,6 +81,35 @@ agents:
|
||||
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
||||
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
||||
|
||||
## Semantic handoff
|
||||
|
||||
`parseRosterV2` and `normalizeRosterV2` remain synchronous and structural. After structural success,
|
||||
call the asynchronous `validateRosterV2Semantics` handoff before using persona identity or authority.
|
||||
That validator batches the baseline `fleet/roles/` and operator `fleet/roles.local/` scans, then
|
||||
delegates every agent to the shared persona resolver.
|
||||
|
||||
Semantic validation:
|
||||
|
||||
- requires the winning role contract to be readable and non-empty; `LIBRARY.md` membership alone does
|
||||
not resolve a class;
|
||||
- retains `requestedClass` separately from `canonicalClass` in typed output;
|
||||
- canonicalizes only `implementer` to `code`, `reviewer` to `review`, and
|
||||
`operator-interaction` to `interaction`;
|
||||
- canonicalizes `tool_policy` with the same exact alias table;
|
||||
- rejects protected class/tool-policy mismatches in either direction, while accepting
|
||||
`class: operator-interaction` with `tool_policy: operator-interaction` as canonical
|
||||
`interaction`;
|
||||
- derives immutable protected authority only from canonical class; and
|
||||
- accepts custom baseline or `roles.local` classes without granting protected authority.
|
||||
|
||||
`agents[].alias` remains display-only. Tess and Ultron are instance names, never semantic classes.
|
||||
Canonicalization happens before role-layer lookup, so a legacy-named override cannot redefine an
|
||||
alias as separate authority. See [Role Classes and Authority](./role-classes.md) and
|
||||
[Customize Fleet Roles](../how-to/customize-roles.md).
|
||||
|
||||
This handoff performs no filesystem, systemd, tmux, roster, credential, lease, certificate, or
|
||||
lifecycle mutation.
|
||||
|
||||
## Fail-closed boundary
|
||||
|
||||
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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,140 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## Reconciliation baseline
|
||||
|
||||
- Prospective remediation handoff: `web1:coder1`; PR [#757](https://git.mosaicstack.dev/mosaicstack/stack/pulls/757), issue [#755](https://git.mosaicstack.dev/mosaicstack/stack/issues/755).
|
||||
- Before rebase: `dff8ce4f79ef90370c29d925002118a708010091`; required base: `origin/main` at `2e2280070ae67288be45f41743cf67052a8ca5a6`; original branch base: `d0771835542deab048ad8e79f271e3abdb6151f7`.
|
||||
- Provider metadata: #757 is open, targets `main`, head is `feat/mos-logical-identity-fencing`, prior CI is green, and the provider reports it is not mergeable because of conflicts.
|
||||
- Affected delivery paths: `apps/gateway/src/agent/agent.module.ts`; connector-lease gateway repository/service and three focused tests; `packages/agent/src/connector-lease.ts` plus test/export; `packages/types/src/agent/connector-lease.dto.ts` plus test/export; `packages/db/src/schema.ts`, migration `0016_salty_morlocks.sql`, Drizzle snapshot/journal; `docs/PRD.md`, `docs/SITEMAP.md`, MOS architecture/operations pages, this scratchpad, and `docs/tess/TASKS.md`.
|
||||
- Read-only merge-tree inspection found only `docs/PRD.md` and `docs/SITEMAP.md` conflicts. Current-main #756 channel contracts, #758 roster-v2 structural compiler, and Native Kanban SOT use distinct contract domains; no substantive architecture collision was identified before mechanical reconciliation.
|
||||
- Reconciliation constraints: retain all current-main #752/#756/#758/KBN content; add only nonduplicative #755 references; do not alter semantics or conflate connector leases/grants with Kanban task leases/fences, local Fleet leases, auth sessions, ResetSession generations, or federation grants.
|
||||
|
||||
## 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.
|
||||
|
||||
## Current-main reconciliation (2026-07-14)
|
||||
|
||||
- Rebased the existing PR branch from `dff8ce4f79ef90370c29d925002118a708010091` (old base `d0771835542deab048ad8e79f271e3abdb6151f7`) onto `origin/main` `2e2280070ae67288be45f41743cf67052a8ca5a6`; current uncommitted reconciliation head is `d190732a550918161b91d3eb54640f4ea0e2e499`.
|
||||
- Resolved only `docs/PRD.md` and `docs/SITEMAP.md`: preserved current-main #752 Native Kanban, #756 official-channel, and #758 FCM material; retained the nonduplicative #755 M1 workstream and placed its two documentation links in the existing Runtime-neutral Mos section. No #755 source semantics changed.
|
||||
- Compatibility review confirmed separate authority domains: connector lease/epoch/grant remains distinct from KBN task leases/fences, local Fleet roster lifecycle, auth sessions, ResetSession context, and federation grants. No channel cutover, adapter activation, checkpoint/exactly-once behavior, UI convergence, or #754 expansion was added.
|
||||
- Focused verification after building the required workspace dependencies: types contract 6/6; agent fencing/grant 10/10; gateway repository/PGlite and policy integration 8/8. The focused real-PostgreSQL test was skipped because `DATABASE_URL` was not configured; no credentials were inspected or emitted.
|
||||
- Generated schema check: `pnpm --filter @mosaicstack/db db:generate` reported no schema changes; migration `0016_salty_morlocks`, snapshot, and journal were unchanged by generation.
|
||||
- Full verification: `pnpm typecheck` 42/42; `pnpm lint` 23/23; `pnpm format:check` passed; `pnpm test` 42/42 (gateway 627 passed / 12 environment-gated skipped). Scoped diff check and local documentation-link scan passed.
|
||||
- Pending only: commit this reconciliation record, queue guard, force-with-lease push of the rebased existing branch, then fresh independent DB/code/security review and Ultron. Do not claim merge or issue closure.
|
||||
108
docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md
Normal file
108
docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# FCM-M1-002 — Shared role resolution
|
||||
|
||||
- **Task:** `FCM-M1-002`
|
||||
- **Issue:** `mosaicstack/stack#758`
|
||||
- **Branch:** `feat/758-shared-role-resolution`
|
||||
- **Starting head:** `32e75c67b094de443d37fe7d5ff8d25cdfc8b39d`
|
||||
- **Role:** implementation worker; independent review and merge remain outside this worker
|
||||
|
||||
## Objective
|
||||
|
||||
Reuse the existing baseline-plus-`roles.local` persona resolver as the sole class authority for roster-v2 semantics, profile validation, provisioning, and launch/persona resolution. Add exact approved alias canonicalization, fail-closed semantic validation, immutable canonical-class authority contracts, required baseline roles, and operator documentation without implementing lifecycle, mutation, credentials, certificate workflow, or later FCM cards.
|
||||
|
||||
## Budget
|
||||
|
||||
- Soft budget: **25K tokens**.
|
||||
- Strategy: inspect once, implement in small TDD units, run focused suites before the full package gate, and avoid unrelated refactors or M1-003/M2/M4 scope.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Map the existing persona resolver, roster-v2 compiler, profile/provision consumers, launch resolution, role library, and focused tests.
|
||||
2. Write denial/invariant tests first for aliases, canonicalization-before-override, unreadable roles, authority boundaries, policy mismatch, canonical provision output, and resolver parity.
|
||||
3. Run the focused suites and record the expected red evidence.
|
||||
4. Implement one shared canonical resolution and authority contract in/through `fleet-personas.ts`; delegate roster semantic validation and profile/provision paths to it.
|
||||
5. Add baseline `validator`, `team-leader`, and `interaction` role contracts plus `LIBRARY.md` entries while retaining `operator-interaction` compatibility.
|
||||
6. Add the required role reference, alias migration, customization guide, and roster-v2 semantic handoff documentation.
|
||||
7. Run focused tests, the full `@mosaicstack/mosaic` suite, typecheck, lint, Prettier, `git diff --check`, situational verification, independent code/security review, and remediation.
|
||||
8. Commit with the required co-author trailer, run the CI queue guard, push the existing branch, and create/update exactly one PR to `main` with `Refs #758`.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
### Red
|
||||
|
||||
After installing worktree-local dependencies and building `@mosaicstack/db`, the pre-implementation
|
||||
focused run collected the intended tests and failed as expected:
|
||||
|
||||
```text
|
||||
2 test files failed; 32 tests failed; 32 tests passed
|
||||
```
|
||||
|
||||
Expected failures named the missing `canonicalizeRoleClass`,
|
||||
`authorityForCanonicalClass`, and `validateRosterV2Semantics` APIs, absent requested/canonical typed
|
||||
output, and unresolved required canonical role contracts. An earlier run that failed before test
|
||||
collection on an unresolved `yaml` dependency was treated as environment setup, not TDD evidence.
|
||||
|
||||
### Green
|
||||
|
||||
Focused role-resolution and affected service fixtures:
|
||||
|
||||
```text
|
||||
6 test files passed; 109 tests passed
|
||||
```
|
||||
|
||||
The focused set covers personas, profiles, provision, launch persona contract, roster-v2 semantics,
|
||||
and the operator-interaction service fixture. The final profile tests also cover readable lead/floor
|
||||
compatibility and canonical collision denial.
|
||||
|
||||
## Tests and gates
|
||||
|
||||
- Focused suites: pass, **6 files / 109 tests**.
|
||||
- Full `@mosaicstack/mosaic` suite: pass, **50 files / 713 tests**. Workspace package build outputs
|
||||
were prepared first because a clean worktree has no dependency `dist` entries.
|
||||
- `pnpm --filter @mosaicstack/mosaic typecheck`: pass.
|
||||
- `pnpm --filter @mosaicstack/mosaic lint`: pass.
|
||||
- Prettier check over every changed file: pass.
|
||||
- `git diff --check`: pass.
|
||||
- Runtime/file-boundary evidence: real role library, profile/provision filesystem integration,
|
||||
launch-time synchronous contract injection, v1 roster parser round-trip, roster-v2 semantic
|
||||
filesystem checks, and operator-interaction service fixtures all pass without live mutation.
|
||||
- Independent code review: **APPROVE**, no blocking or non-blocking findings; reviewed complete
|
||||
tracked/untracked delta including the canonical collision guard. Residual: roster-v2 semantic
|
||||
validation is an explicit async handoff with production caller wiring owned by later work.
|
||||
- Independent security review: **APPROVE**, no verified authority/security findings on the final
|
||||
delta.
|
||||
|
||||
|
||||
## Risks and boundaries
|
||||
|
||||
- **Security-sensitive authority:** authority must derive only from canonical class, never role prose, aliases, display names, or tool-policy text.
|
||||
- **Resolver divergence:** no second regex, registry, scanner, or prose parser may be introduced.
|
||||
- **Alias capture:** aliases must canonicalize before baseline/`roles.local` lookup so local files cannot redefine legacy aliases as separate authority.
|
||||
- **Readable persona requirement:** semantic success requires a resolved readable persona, not class-set membership.
|
||||
- **Scope control:** no roster mutation, lifecycle, lease issuance, certificate workflow/storage, credentials, remote reconciliation, provision-v2 conversion, or shipped-example disposition execution.
|
||||
- **Coordination:** `docs/TASKS.md` is read-only and remains orchestrator-owned.
|
||||
|
||||
## Acceptance-evidence mapping
|
||||
|
||||
| Requirement / criterion | Verification evidence |
|
||||
| --- | --- |
|
||||
| `FCM-REQ-02` shared semantic resolver | Async/sync resolver parity; roster-v2 delegates batched scans and resolution; profiles/provision and launch reuse `fleet-personas.ts`; no second scanner or class-marker regex added. |
|
||||
| `FCM-REQ-07` canonical classes and authority boundaries | Exact alias and non-alias tests; immutable authority invariant tests; all required canonical contracts resolve through the real role library. |
|
||||
| `AC-FCM-01` structural + semantic roster validation | Synchronous parser/normalizer tests remain intact; async semantic tests cover aliases, custom roles, unreadable/unresolved roles, `LIBRARY`-only rejection, and bidirectional protected policy mismatch. |
|
||||
| `AC-FCM-07` protected authority invariants | Denial tests prove merge-gate-only merge, validator certificate-only, orchestrator/team-leader/interaction limits, no implicit custom-role authority, and canonical tool-policy matching. |
|
||||
|
||||
## Documentation
|
||||
|
||||
- `docs/fleet/reference/role-classes.md`
|
||||
- `docs/fleet/migration/legacy-class-aliases.md`
|
||||
- `docs/fleet/how-to/customize-roles.md`
|
||||
- `docs/fleet/reference/roster-v2-fields.md` semantic handoff
|
||||
- Baseline role contracts and `LIBRARY.md` rows for `validator`, `team-leader`, and `interaction`
|
||||
|
||||
## Residual risks
|
||||
|
||||
- Provisioning remains intentionally v1 and does not emit `reports_to`; canonical topology is retained
|
||||
in its typed seat/summary path only, matching the existing v1 parser boundary.
|
||||
- Alias support remains for compatibility; new configuration should emit canonical identities.
|
||||
- This card defines authority metadata and validation only. Enforcement workflows for leases,
|
||||
certificates, lifecycle, and mutation remain owned by later FCM cards.
|
||||
@@ -43,4 +43,3 @@
|
||||
| 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. |
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,381 +0,0 @@
|
||||
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,4 +5,3 @@ 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';
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,13 +113,6 @@
|
||||
"when": 1783942610000,
|
||||
"tag": "0015_interaction_checkpoint_payload_digest",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1784050648841,
|
||||
"tag": "0016_salty_morlocks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
uniqueIndex,
|
||||
real,
|
||||
integer,
|
||||
bigint,
|
||||
customType,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
@@ -488,68 +487,6 @@ 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.
|
||||
|
||||
@@ -12,19 +12,22 @@ on demand. Engineering personas have no explicit `domain:` marker (they are the
|
||||
implicit `engineering` domain); cross-domain personas carry a `domain:` key in
|
||||
their intro so tooling can group them.
|
||||
|
||||
> This file is an index only — no code imports it. To add a persona, drop a new
|
||||
> `*.md` next to the others (mirroring the existing structure) and add a row here.
|
||||
> This file is an index, not an authority source. The fleet persona resolver reads
|
||||
> its rows for discovery compatibility, then requires a readable `*.md` contract;
|
||||
> authority is derived from canonical class metadata in code, never from this prose.
|
||||
|
||||
## engineering
|
||||
|
||||
| Persona | Purpose |
|
||||
| --------------- | ------------------------------------------------------------------------------ |
|
||||
| orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work |
|
||||
| team-leader | Coordinates only orchestrator-leased capacity for one bounded project |
|
||||
| board | Multi-lens deliberation panel; owns the mission's direction, not its execution |
|
||||
| planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG |
|
||||
| decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges |
|
||||
| code | Primary executor — one card, one branch, one PR to green CI |
|
||||
| review | Correctness reviewer — judges an open PR on correctness, scope, and coverage |
|
||||
| validator | Independent final evidence certificate; never approves-to-land or merges |
|
||||
| security-review | Second line of review — secrets, auth, and forbidden-path safety |
|
||||
| site-tester | Runtime verifier — runs the change and checks behavior vs. acceptance criteria |
|
||||
| documentation | Prose maintainer — keeps human-facing docs and projections in sync |
|
||||
@@ -33,6 +36,7 @@ their intro so tooling can group them.
|
||||
| operator | Escalation and control surface — owns exceptions and the fleet pause switch |
|
||||
| session-review | Post-task retrospective — turns finished work into improvement signals |
|
||||
| enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness |
|
||||
| interaction | Operator request/status surface; routes orchestration and merge decisions |
|
||||
|
||||
## executive
|
||||
|
||||
|
||||
16
packages/mosaic/framework/fleet/roles/interaction.md
Normal file
16
packages/mosaic/framework/fleet/roles/interaction.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Interaction — fleet role definition
|
||||
|
||||
The **interaction** role (`class: interaction`) is the operator-facing request and status surface for Mosaic.
|
||||
|
||||
## Mandate
|
||||
|
||||
1. Receive operator requests and present observable fleet or runtime status.
|
||||
2. Route orchestration requests to the orchestrator and merge decisions to the merge-gate.
|
||||
3. Report supported actions and their outcomes without claiming another role's authority.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Request/status only; it does not orchestrate, issue leases, approve-to-land, or merge.
|
||||
- It does not mutate roster configuration, role authority, or credentials.
|
||||
- A configured instance name such as Tess is display data, never a class or authority source.
|
||||
- `operator-interaction` remains a compatibility alias for this canonical class.
|
||||
16
packages/mosaic/framework/fleet/roles/team-leader.md
Normal file
16
packages/mosaic/framework/fleet/roles/team-leader.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Team leader — fleet role definition
|
||||
|
||||
The **team-leader** (`class: team-leader`) coordinates a bounded project team using only capacity granted by an orchestrator-issued lease.
|
||||
|
||||
## Mandate
|
||||
|
||||
1. Direct the leased coder, reviewer, and validator capacity for the assigned project scope.
|
||||
2. Track delivery status and return results or blockers to the orchestrator.
|
||||
3. Stop using capacity when the lease or assignment ends.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Leased capacity only; this role does not issue or expand its own lease.
|
||||
- It cannot change fleet roster membership, role authority, fleet configuration, or credentials.
|
||||
- It cannot approve-to-land or merge.
|
||||
- It does not displace the orchestrator's topology and lease authority.
|
||||
16
packages/mosaic/framework/fleet/roles/validator.md
Normal file
16
packages/mosaic/framework/fleet/roles/validator.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Validator — fleet role definition
|
||||
|
||||
The **validator** (`class: validator`) is the independent final evidence seat. It examines the accepted requirements, test evidence, review record, and candidate head and may issue a validation certificate for that exact evidence set.
|
||||
|
||||
## Mandate
|
||||
|
||||
1. Validate acceptance evidence independently from the implementation author.
|
||||
2. Issue or withhold a final validation certificate for the reviewed candidate.
|
||||
3. Report missing, stale, or contradictory evidence without altering it.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- **Certificate only:** the validator does not approve-to-land or merge.
|
||||
- It does not replace correctness or security review.
|
||||
- It does not write product code, mutate the roster, issue leases, or access credentials.
|
||||
- A configured instance name such as Ultron is display data, never a class or authority source.
|
||||
@@ -4,10 +4,13 @@ import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
authorityForCanonicalClass,
|
||||
canonicalizeRoleClass,
|
||||
extractClassesFromDir,
|
||||
listPersonaClasses,
|
||||
personaStatus,
|
||||
resolvePersona,
|
||||
resolvePersonaSync,
|
||||
} from './fleet-personas.js';
|
||||
import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js';
|
||||
|
||||
@@ -54,6 +57,122 @@ afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('FCM class canonicalization and authority', () => {
|
||||
it.each([
|
||||
['implementer', 'code'],
|
||||
['reviewer', 'review'],
|
||||
['operator-interaction', 'interaction'],
|
||||
])('canonicalizes the approved alias %s to %s', (requested: string, canonical: string) => {
|
||||
expect(canonicalizeRoleClass(requested)).toEqual({
|
||||
requestedClass: requested,
|
||||
canonicalClass: canonical,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron'])(
|
||||
'does not infer an alias for %s',
|
||||
(requested: string) => {
|
||||
expect(canonicalizeRoleClass(requested)).toEqual({
|
||||
requestedClass: requested,
|
||||
canonicalClass: requested,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('canonicalizes before override lookup and lets the canonical override win', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(overrideDir, 'implementer.md'),
|
||||
overridePersona('implementer', 'legacy'),
|
||||
'utf8',
|
||||
);
|
||||
await writeFile(
|
||||
join(overrideDir, 'code.md'),
|
||||
overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const resolved = await resolvePersona('implementer', { rolesDir, overrideDir });
|
||||
expect(resolved).toMatchObject({
|
||||
requestedClass: 'implementer',
|
||||
canonicalClass: 'code',
|
||||
klass: 'code',
|
||||
layer: 'override',
|
||||
});
|
||||
expect(resolved?.content).toContain('CANONICAL-OVERRIDE');
|
||||
expect(resolved?.content).not.toContain('legacy');
|
||||
});
|
||||
|
||||
it('keeps sync and async resolution equivalent for aliases and canonical overrides', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(overrideDir, 'code.md'),
|
||||
overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const asyncResolution = await resolvePersona('implementer', { rolesDir, overrideDir });
|
||||
const syncResolution = resolvePersonaSync('implementer', { rolesDir, overrideDir });
|
||||
expect(syncResolution).toEqual(asyncResolution);
|
||||
});
|
||||
|
||||
it('derives immutable protected authority only from the canonical class', () => {
|
||||
expect(authorityForCanonicalClass('merge-gate')).toMatchObject({ mayMerge: true });
|
||||
for (const klass of [
|
||||
'validator',
|
||||
'orchestrator',
|
||||
'team-leader',
|
||||
'interaction',
|
||||
'code',
|
||||
'custom-role',
|
||||
]) {
|
||||
expect(authorityForCanonicalClass(klass).mayMerge).toBe(false);
|
||||
}
|
||||
expect(authorityForCanonicalClass('validator')).toMatchObject({
|
||||
mayIssueValidationCertificate: true,
|
||||
mayMerge: false,
|
||||
});
|
||||
expect(authorityForCanonicalClass('orchestrator')).toMatchObject({
|
||||
mayOrchestrate: true,
|
||||
mayIssueLeases: true,
|
||||
mayMerge: false,
|
||||
});
|
||||
expect(authorityForCanonicalClass('team-leader')).toMatchObject({
|
||||
leasedCapacityOnly: true,
|
||||
mayMutateRoster: false,
|
||||
mayAccessCredentials: false,
|
||||
mayMerge: false,
|
||||
});
|
||||
expect(authorityForCanonicalClass('interaction')).toMatchObject({
|
||||
requestStatusOnly: true,
|
||||
mayOrchestrate: false,
|
||||
mayMerge: false,
|
||||
});
|
||||
expect(Object.isFrozen(authorityForCanonicalClass('merge-gate'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required FCM role library', () => {
|
||||
it.each([
|
||||
'code',
|
||||
'review',
|
||||
'validator',
|
||||
'orchestrator',
|
||||
'team-leader',
|
||||
'enhancer',
|
||||
'interaction',
|
||||
'merge-gate',
|
||||
])('resolves %s through the real framework role library', async (klass: string) => {
|
||||
const resolved = await resolvePersona(klass, {
|
||||
rolesDir: realRolesDir,
|
||||
overrideDir: join(tmp, 'none'),
|
||||
});
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved?.canonicalClass).toBe(klass);
|
||||
expect(resolved?.content.trim()).not.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractClassesFromDir (shared extraction)', () => {
|
||||
it('records class + domain from inline markers and degrades on missing dir', async () => {
|
||||
const base = await extractClassesFromDir(rolesDir);
|
||||
|
||||
@@ -59,6 +59,73 @@ const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm;
|
||||
/** Where a resolved persona's definition came from. */
|
||||
export type PersonaLayer = 'baseline' | 'override';
|
||||
|
||||
export const ROLE_CLASS_ALIASES = Object.freeze({
|
||||
implementer: 'code',
|
||||
reviewer: 'review',
|
||||
'operator-interaction': 'interaction',
|
||||
} as const);
|
||||
|
||||
export interface CanonicalRoleClass {
|
||||
readonly requestedClass: string;
|
||||
readonly canonicalClass: string;
|
||||
}
|
||||
|
||||
/** Canonicalize only the three explicitly approved compatibility aliases. */
|
||||
export function canonicalizeRoleClass(requestedClass: string): CanonicalRoleClass {
|
||||
const requested = requestedClass.trim();
|
||||
const canonical = ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES] ?? requested;
|
||||
return Object.freeze({ requestedClass: requested, canonicalClass: canonical });
|
||||
}
|
||||
|
||||
export interface RoleAuthority {
|
||||
readonly mayMerge: boolean;
|
||||
readonly mayIssueValidationCertificate: boolean;
|
||||
readonly mayOrchestrate: boolean;
|
||||
readonly mayManageTopology: boolean;
|
||||
readonly mayIssueLeases: boolean;
|
||||
readonly leasedCapacityOnly: boolean;
|
||||
readonly requestStatusOnly: boolean;
|
||||
readonly mayMutateRoster: boolean;
|
||||
readonly mayMutateConfiguration: boolean;
|
||||
readonly mayAccessCredentials: boolean;
|
||||
}
|
||||
|
||||
const NO_PROTECTED_AUTHORITY: RoleAuthority = Object.freeze({
|
||||
mayMerge: false,
|
||||
mayIssueValidationCertificate: false,
|
||||
mayOrchestrate: false,
|
||||
mayManageTopology: false,
|
||||
mayIssueLeases: false,
|
||||
leasedCapacityOnly: false,
|
||||
requestStatusOnly: false,
|
||||
mayMutateRoster: false,
|
||||
mayMutateConfiguration: false,
|
||||
mayAccessCredentials: false,
|
||||
});
|
||||
|
||||
/** Immutable authority contracts keyed only by canonical class identity. */
|
||||
export const ROLE_AUTHORITY_BY_CANONICAL_CLASS: Readonly<Record<string, RoleAuthority>> =
|
||||
Object.freeze({
|
||||
'merge-gate': Object.freeze({ ...NO_PROTECTED_AUTHORITY, mayMerge: true }),
|
||||
validator: Object.freeze({
|
||||
...NO_PROTECTED_AUTHORITY,
|
||||
mayIssueValidationCertificate: true,
|
||||
}),
|
||||
orchestrator: Object.freeze({
|
||||
...NO_PROTECTED_AUTHORITY,
|
||||
mayOrchestrate: true,
|
||||
mayManageTopology: true,
|
||||
mayIssueLeases: true,
|
||||
}),
|
||||
'team-leader': Object.freeze({ ...NO_PROTECTED_AUTHORITY, leasedCapacityOnly: true }),
|
||||
interaction: Object.freeze({ ...NO_PROTECTED_AUTHORITY, requestStatusOnly: true }),
|
||||
});
|
||||
|
||||
/** Return protected authority for an already-canonical class; custom classes get none. */
|
||||
export function authorityForCanonicalClass(canonicalClass: string): RoleAuthority {
|
||||
return ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass] ?? NO_PROTECTED_AUTHORITY;
|
||||
}
|
||||
|
||||
/** One discovered persona file (a single role contract on disk). */
|
||||
export interface PersonaFile {
|
||||
klass: string;
|
||||
@@ -210,7 +277,8 @@ export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<st
|
||||
|
||||
export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
|
||||
|
||||
export interface PersonaResolution {
|
||||
export interface PersonaResolution extends CanonicalRoleClass {
|
||||
/** Compatibility name for the canonical class. */
|
||||
klass: string;
|
||||
layer: PersonaLayer;
|
||||
/** The file the resolved persona was read from (override wins). */
|
||||
@@ -245,9 +313,11 @@ export async function resolvePersona(
|
||||
* is identical to {@link resolvePersona}: override layer wins, then baseline.
|
||||
*/
|
||||
export async function resolvePersonaFrom(
|
||||
klass: string,
|
||||
requestedClass: string,
|
||||
layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
|
||||
): Promise<PersonaResolution | null> {
|
||||
const { requestedClass: requested, canonicalClass: klass } =
|
||||
canonicalizeRoleClass(requestedClass);
|
||||
const { rolesDir, overrideDir, base, over } = layers;
|
||||
|
||||
const fromLayer = async (
|
||||
@@ -264,14 +334,30 @@ export async function resolvePersonaFrom(
|
||||
try {
|
||||
const content = await readFile(byName, 'utf8');
|
||||
const dm = DOMAIN_MARKER.exec(content);
|
||||
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
|
||||
return {
|
||||
requestedClass: requested,
|
||||
canonicalClass: klass,
|
||||
klass,
|
||||
layer,
|
||||
file: byName,
|
||||
content,
|
||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = await readFile(pf.file, 'utf8');
|
||||
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
|
||||
return {
|
||||
requestedClass: requested,
|
||||
canonicalClass: klass,
|
||||
klass,
|
||||
layer,
|
||||
file: pf.file,
|
||||
content,
|
||||
...(pf.domain ? { domain: pf.domain } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -292,9 +378,11 @@ export async function resolvePersonaFrom(
|
||||
* one module so the launch-time and command-time resolutions never diverge.
|
||||
*/
|
||||
export function resolvePersonaSync(
|
||||
klass: string,
|
||||
requestedClass: string,
|
||||
opts: PersonaDirs = {},
|
||||
): PersonaResolution | null {
|
||||
const { requestedClass: requested, canonicalClass: klass } =
|
||||
canonicalizeRoleClass(requestedClass);
|
||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||
const base = extractClassesFromDirSync(rolesDir);
|
||||
const over = extractClassesFromDirSync(overrideDir);
|
||||
@@ -312,14 +400,30 @@ export function resolvePersonaSync(
|
||||
try {
|
||||
const content = readFileSync(byName, 'utf8');
|
||||
const dm = DOMAIN_MARKER.exec(content);
|
||||
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
|
||||
return {
|
||||
requestedClass: requested,
|
||||
canonicalClass: klass,
|
||||
klass,
|
||||
layer,
|
||||
file: byName,
|
||||
content,
|
||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = readFileSync(pf.file, 'utf8');
|
||||
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
|
||||
return {
|
||||
requestedClass: requested,
|
||||
canonicalClass: klass,
|
||||
klass,
|
||||
layer,
|
||||
file: pf.file,
|
||||
content,
|
||||
...(pf.domain ? { domain: pf.domain } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -203,6 +203,91 @@ describe('loadProfiles with a temp override dir', () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('canonicalizes approved aliases across lead, floor, roster, and topology', async () => {
|
||||
await writeFile(
|
||||
join(dir, 'aliases.yaml'),
|
||||
[
|
||||
'id: aliases',
|
||||
'title: Aliases',
|
||||
'description: legacy class compatibility',
|
||||
'lead: operator-interaction',
|
||||
'floor: [operator-interaction]',
|
||||
'roster:',
|
||||
' - class: operator-interaction',
|
||||
' - class: implementer',
|
||||
' reports_to: operator-interaction',
|
||||
' - class: reviewer',
|
||||
' reports_to: operator-interaction',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const profile = await loadProfile('aliases', {
|
||||
profilesDir: dir,
|
||||
rolesDir,
|
||||
overrideDir: join(dir, 'roles.local'),
|
||||
});
|
||||
expect(profile.lead).toBe('interaction');
|
||||
expect(profile.floor).toEqual(['interaction']);
|
||||
expect(profile.roster).toEqual([
|
||||
{ class: 'interaction', multiplicity: 1 },
|
||||
{ class: 'code', reportsTo: 'interaction', multiplicity: 1 },
|
||||
{ class: 'review', reportsTo: 'interaction', multiplicity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects roster entries that collapse to the same canonical class', async () => {
|
||||
await writeFile(
|
||||
join(dir, 'alias-collision.yaml'),
|
||||
[
|
||||
'id: alias-collision',
|
||||
'title: Alias collision',
|
||||
'description: ambiguous canonical topology',
|
||||
'lead: orchestrator',
|
||||
'floor: [orchestrator]',
|
||||
'roster:',
|
||||
' - class: orchestrator',
|
||||
' - class: code',
|
||||
' reports_to: orchestrator',
|
||||
' - class: implementer',
|
||||
' reports_to: orchestrator',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadProfile('alias-collision', {
|
||||
profilesDir: dir,
|
||||
rolesDir,
|
||||
overrideDir: join(dir, 'roles.local'),
|
||||
}),
|
||||
).rejects.toThrow(/duplicate classes after canonicalization/);
|
||||
});
|
||||
|
||||
it('allows a readable lead or floor persona that is not itself a roster seat', async () => {
|
||||
await writeFile(
|
||||
join(dir, 'external-topology.yaml'),
|
||||
[
|
||||
'id: external-topology',
|
||||
'title: External topology',
|
||||
'description: structural compatibility',
|
||||
'lead: orchestrator',
|
||||
'floor: [enhancer]',
|
||||
'roster:',
|
||||
' - class: code',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const profile = await loadProfile('external-topology', {
|
||||
profilesDir: dir,
|
||||
rolesDir,
|
||||
overrideDir: join(dir, 'roles.local'),
|
||||
});
|
||||
expect(profile.lead).toBe('orchestrator');
|
||||
expect(profile.floor).toEqual(['enhancer']);
|
||||
});
|
||||
|
||||
it('throws when a profile references an unknown class (validated against real roles)', async () => {
|
||||
await writeFile(
|
||||
join(dir, 'bad.yaml'),
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
defaultOverrideDir,
|
||||
extractClassesFromDir,
|
||||
listPersonaClasses as listOverrideAwarePersonaClasses,
|
||||
resolvePersonaFrom,
|
||||
} from './fleet-personas.js';
|
||||
|
||||
function defaultMosaicHome(): string {
|
||||
@@ -199,6 +200,61 @@ export function validateProfile(profile: FleetProfile, validClasses: Set<string>
|
||||
return problems;
|
||||
}
|
||||
|
||||
export async function resolveProfilePersonas(
|
||||
profile: FleetProfile,
|
||||
rolesDir: string,
|
||||
overrideDir: string,
|
||||
): Promise<FleetProfile> {
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
const requestedClasses = new Set<string>([
|
||||
profile.lead,
|
||||
...profile.floor,
|
||||
...profile.roster.flatMap((entry: ProfileRosterEntry): string[] =>
|
||||
entry.reportsTo ? [entry.class, entry.reportsTo] : [entry.class],
|
||||
),
|
||||
]);
|
||||
const canonicalByRequested = new Map<string, string>();
|
||||
for (const requestedClass of requestedClasses) {
|
||||
const resolved = await resolvePersonaFrom(requestedClass, {
|
||||
rolesDir,
|
||||
overrideDir,
|
||||
base,
|
||||
over,
|
||||
});
|
||||
if (!resolved || resolved.content.trim() === '') {
|
||||
throw new Error(`persona class "${requestedClass}" does not resolve to a readable persona`);
|
||||
}
|
||||
canonicalByRequested.set(requestedClass, resolved.canonicalClass);
|
||||
}
|
||||
const canonical = (requestedClass: string): string =>
|
||||
canonicalByRequested.get(requestedClass) ?? requestedClass;
|
||||
const roster = profile.roster.map(
|
||||
(entry: ProfileRosterEntry): ProfileRosterEntry => ({
|
||||
class: canonical(entry.class),
|
||||
multiplicity: entry.multiplicity,
|
||||
...(entry.reportsTo ? { reportsTo: canonical(entry.reportsTo) } : {}),
|
||||
}),
|
||||
);
|
||||
const canonicalRosterClasses = roster.map((entry: ProfileRosterEntry): string => entry.class);
|
||||
if (new Set(canonicalRosterClasses).size !== canonicalRosterClasses.length) {
|
||||
throw new Error('profile roster contains duplicate classes after canonicalization');
|
||||
}
|
||||
const resolvedProfile: FleetProfile = {
|
||||
...profile,
|
||||
lead: canonical(profile.lead),
|
||||
floor: profile.floor.map(canonical),
|
||||
roster,
|
||||
};
|
||||
const problems = validateProfile(resolvedProfile, new Set(canonicalByRequested.values()));
|
||||
if (problems.length > 0) {
|
||||
throw new Error(problems.join('\n - '));
|
||||
}
|
||||
return resolvedProfile;
|
||||
}
|
||||
|
||||
export interface LoadProfilesOptions {
|
||||
/** Override the profiles dir (tests). Defaults to <mosaicHome>/fleet/profiles. */
|
||||
profilesDir?: string;
|
||||
@@ -237,10 +293,8 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
|
||||
}
|
||||
files.sort();
|
||||
|
||||
// Override-aware: a profile may reference a user-customized or user-ADDED
|
||||
// persona living in the roles.local/ layer (H4), so validate against the
|
||||
// baseline ⊕ override union, not the baseline alone.
|
||||
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
|
||||
// Validation resolves each reference to an actually readable winning persona;
|
||||
// LIBRARY membership alone is not semantic success.
|
||||
const profiles: FleetProfile[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
|
||||
@@ -255,11 +309,14 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
|
||||
}
|
||||
seen.set(profile.id, file);
|
||||
|
||||
const problems = validateProfile(profile, validClasses);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`Profile ${file} is invalid:\n - ${problems.join('\n - ')}`);
|
||||
let resolvedProfile: FleetProfile;
|
||||
try {
|
||||
resolvedProfile = await resolveProfilePersonas(profile, rolesDir, overrideDir);
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Profile ${file} is invalid:\n - ${detail}`);
|
||||
}
|
||||
profiles.push(profile);
|
||||
profiles.push(resolvedProfile);
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
@@ -172,6 +172,46 @@ describe('override-aware persona validation', () => {
|
||||
expect(result.summary).toContain('persona=override');
|
||||
});
|
||||
|
||||
it('canonicalizes aliased profile classes and topology identities before emission', async () => {
|
||||
const overrideDir = join(dir, 'roles.local');
|
||||
const customProfilesDir = join(dir, 'profiles');
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await mkdir(customProfilesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(customProfilesDir, 'aliases.yaml'),
|
||||
[
|
||||
'id: aliases',
|
||||
'title: Aliases',
|
||||
'description: legacy aliases',
|
||||
'lead: operator-interaction',
|
||||
'floor: [operator-interaction]',
|
||||
'roster:',
|
||||
' - class: operator-interaction',
|
||||
' - class: implementer',
|
||||
' reports_to: operator-interaction',
|
||||
' - class: reviewer',
|
||||
' reports_to: operator-interaction',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await runProvision('aliases', {
|
||||
mosaicHome: dir,
|
||||
profilesDir: customProfilesDir,
|
||||
rolesDir,
|
||||
overrideDir,
|
||||
full: true,
|
||||
});
|
||||
expect(result.yaml).toContain('name: interaction');
|
||||
expect(result.yaml).toContain('class: interaction');
|
||||
expect(result.yaml).toContain('name: code');
|
||||
expect(result.yaml).toContain('class: code');
|
||||
expect(result.yaml).toContain('name: review');
|
||||
expect(result.yaml).toContain('class: review');
|
||||
expect(result.yaml).not.toContain('class: implementer');
|
||||
expect(result.summary).toContain('reports_to=interaction');
|
||||
});
|
||||
|
||||
it('FAILS with a clear message when a profile references a bogus class', async () => {
|
||||
const customProfilesDir = join(dir, 'profiles');
|
||||
await mkdir(customProfilesDir, { recursive: true });
|
||||
|
||||
@@ -26,12 +26,11 @@ import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
import {
|
||||
loadProfile,
|
||||
validateProfile,
|
||||
type FleetProfile,
|
||||
type ProfileRosterEntry,
|
||||
defaultProfilesDir,
|
||||
defaultRolesDir,
|
||||
listPersonaClassesWithOverrides,
|
||||
resolveProfilePersonas,
|
||||
} from './fleet-profiles.js';
|
||||
import {
|
||||
defaultOverrideDir,
|
||||
@@ -201,18 +200,35 @@ export async function generateRoster(
|
||||
);
|
||||
}
|
||||
|
||||
const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead);
|
||||
for (const name of seatNames(entry)) {
|
||||
const canonicalEntry: ProfileRosterEntry = {
|
||||
class: resolved.canonicalClass,
|
||||
multiplicity: entry.multiplicity,
|
||||
...(entry.reportsTo
|
||||
? {
|
||||
reportsTo:
|
||||
(
|
||||
await resolvePersonaFrom(entry.reportsTo, {
|
||||
rolesDir,
|
||||
overrideDir,
|
||||
base,
|
||||
over,
|
||||
})
|
||||
)?.canonicalClass ?? entry.reportsTo,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const runtimeChoice = resolveSeatRuntime(resolved.canonicalClass, isFloor, isLead);
|
||||
for (const name of seatNames(canonicalEntry)) {
|
||||
const seat: GeneratedSeat = {
|
||||
name,
|
||||
className: entry.class,
|
||||
className: resolved.canonicalClass,
|
||||
runtime: runtimeChoice.runtime,
|
||||
personaLayer: resolved.layer,
|
||||
};
|
||||
if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint;
|
||||
if (isFloor || isLead) seat.persistentPersona = true;
|
||||
if (!isFloor && !isLead) seat.resetBetweenTasks = true;
|
||||
if (entry.reportsTo) seat.reportsTo = entry.reportsTo;
|
||||
if (canonicalEntry.reportsTo) seat.reportsTo = canonicalEntry.reportsTo;
|
||||
seats.push(seat);
|
||||
}
|
||||
}
|
||||
@@ -279,13 +295,7 @@ export async function validateProfileForProvision(
|
||||
opts: ProvisionOptions,
|
||||
): Promise<void> {
|
||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
|
||||
const problems = validateProfile(profile, validClasses);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(
|
||||
`Profile "${profile.id}" is invalid; cannot provision:\n - ${problems.join('\n - ')}`,
|
||||
);
|
||||
}
|
||||
await resolveProfilePersonas(profile, rolesDir, overrideDir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -77,12 +77,25 @@ describe('readPersonaContractBlock — launch-time persona injection (A3b)', ()
|
||||
});
|
||||
|
||||
it('injects an override-only (user-added) persona with no baseline at all', () => {
|
||||
seedOverride(home, 'reviewer', '# Reviewer\n\n(`class: reviewer`)\n\nCUSTOM-ROLE.\n');
|
||||
const block = readPersonaContractBlock(home, 'reviewer');
|
||||
expect(block).toContain('# Persona Contract (reviewer)');
|
||||
seedOverride(home, 'mascot', '# Mascot\n\n(`class: mascot`)\n\nCUSTOM-ROLE.\n');
|
||||
const block = readPersonaContractBlock(home, 'mascot');
|
||||
expect(block).toContain('# Persona Contract (mascot)');
|
||||
expect(block).toContain('CUSTOM-ROLE');
|
||||
});
|
||||
|
||||
it('canonicalizes an approved alias before launch-time override lookup', () => {
|
||||
seedBaseline(home, 'code', '# Code\n\n(`class: code`)\n\nCANONICAL-CODE.\n');
|
||||
seedOverride(
|
||||
home,
|
||||
'implementer',
|
||||
'# Legacy implementer\n\n(`class: implementer`)\n\nLEGACY-OVERRIDE.\n',
|
||||
);
|
||||
const block = readPersonaContractBlock(home, 'implementer');
|
||||
expect(block).toContain('# Persona Contract (code)');
|
||||
expect(block).toContain('CANONICAL-CODE');
|
||||
expect(block).not.toContain('LEGACY-OVERRIDE');
|
||||
});
|
||||
|
||||
it('no-ops (empty string) when the class is undefined', () => {
|
||||
seedBaseline(home, 'coder', BASELINE_CODER);
|
||||
expect(readPersonaContractBlock(home, undefined)).toBe('');
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ROSTER_V2_JSON_SCHEMA,
|
||||
RosterV2ValidationError,
|
||||
parseRosterV2,
|
||||
renderRosterV2Yaml,
|
||||
validateRosterV2Semantics,
|
||||
} from './roster-v2.js';
|
||||
|
||||
const validRoster = `
|
||||
@@ -40,6 +43,127 @@ agents:
|
||||
yolo: true
|
||||
`;
|
||||
|
||||
let semanticTmp: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
|
||||
semanticTmp = undefined;
|
||||
});
|
||||
|
||||
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
|
||||
semanticTmp = await mkdtemp(join(tmpdir(), 'roster-v2-semantics-'));
|
||||
const rolesDir = join(semanticTmp, 'roles');
|
||||
const overrideDir = join(semanticTmp, 'roles.local');
|
||||
await mkdir(rolesDir, { recursive: true });
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
for (const klass of [
|
||||
'code',
|
||||
'review',
|
||||
'interaction',
|
||||
'orchestrator',
|
||||
'merge-gate',
|
||||
'validator',
|
||||
'team-leader',
|
||||
]) {
|
||||
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`, 'utf8');
|
||||
}
|
||||
return { rolesDir, overrideDir };
|
||||
}
|
||||
|
||||
function rosterWithClass(klass: string, toolPolicy = klass): string {
|
||||
return validRoster
|
||||
.replace('class: code', `class: ${klass}`)
|
||||
.replace('tool_policy: code', `tool_policy: ${toolPolicy}`);
|
||||
}
|
||||
|
||||
describe('roster v2 semantic validation', (): void => {
|
||||
it.each([
|
||||
['implementer', 'code'],
|
||||
['reviewer', 'review'],
|
||||
['operator-interaction', 'interaction'],
|
||||
])(
|
||||
'canonicalizes requested alias %s while retaining requested and canonical class',
|
||||
async (requested: string, canonical: string) => {
|
||||
const dirs = await semanticDirs();
|
||||
const roster = parseRosterV2(rosterWithClass(requested, requested), 'yaml');
|
||||
const validated = await validateRosterV2Semantics(roster, dirs);
|
||||
expect(validated.agents[0]).toMatchObject({
|
||||
requestedClass: requested,
|
||||
canonicalClass: canonical,
|
||||
canonicalToolPolicy: canonical,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['worker', 'analyst', 'canary'])(
|
||||
'rejects %s when no genuine custom role exists',
|
||||
async (klass: string) => {
|
||||
const dirs = await semanticDirs();
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass), 'yaml'), dirs),
|
||||
).rejects.toThrow(/unresolved|readable persona/i);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts a genuine custom roles.local class without protected authority', async () => {
|
||||
const dirs = await semanticDirs();
|
||||
await writeFile(join(dirs.overrideDir, 'worker.md'), '# worker\n\n(`class: worker`)\n', 'utf8');
|
||||
const validated = await validateRosterV2Semantics(
|
||||
parseRosterV2(rosterWithClass('worker'), 'yaml'),
|
||||
dirs,
|
||||
);
|
||||
expect(validated.agents[0]?.authority).toMatchObject({
|
||||
mayMerge: false,
|
||||
mayOrchestrate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a LIBRARY-only class with no readable resolved persona', async () => {
|
||||
const dirs = await semanticDirs();
|
||||
await writeFile(
|
||||
join(dirs.rolesDir, 'LIBRARY.md'),
|
||||
'| Persona | Purpose |\n| --- | --- |\n| phantom | Missing |\n',
|
||||
'utf8',
|
||||
);
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass('phantom'), 'yaml'), dirs),
|
||||
).rejects.toThrow(/readable persona/i);
|
||||
});
|
||||
|
||||
it('rejects an unreadable resolved persona', async () => {
|
||||
const dirs = await semanticDirs();
|
||||
await mkdir(join(dirs.overrideDir, 'worker.md'));
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs),
|
||||
).rejects.toThrow(/readable persona/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['merge-gate', 'code'],
|
||||
['validator', 'merge-gate'],
|
||||
['orchestrator', 'interaction'],
|
||||
['team-leader', 'orchestrator'],
|
||||
['interaction', 'orchestrator'],
|
||||
['code', 'merge-gate'],
|
||||
['worker', 'validator'],
|
||||
])(
|
||||
'denies protected class/tool-policy mismatch %s with %s',
|
||||
async (klass: string, toolPolicy: string) => {
|
||||
const dirs = await semanticDirs();
|
||||
if (klass === 'worker') {
|
||||
await writeFile(
|
||||
join(dirs.overrideDir, 'worker.md'),
|
||||
'# worker\n\n(`class: worker`)\n',
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass, toolPolicy), 'yaml'), dirs),
|
||||
).rejects.toThrow(/tool policy.*must match|mismatch/i);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('roster v2 structural compiler', (): void => {
|
||||
it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
|
||||
const roster = parseRosterV2(validRoster, 'yaml');
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import YAML from 'yaml';
|
||||
import {
|
||||
authorityForCanonicalClass,
|
||||
canonicalizeRoleClass,
|
||||
defaultOverrideDir,
|
||||
defaultRolesDir,
|
||||
extractClassesFromDir,
|
||||
resolvePersonaFrom,
|
||||
type PersonaDirs,
|
||||
type PersonaResolution,
|
||||
type RoleAuthority,
|
||||
} from '../commands/fleet-personas.js';
|
||||
|
||||
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
|
||||
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
|
||||
@@ -58,6 +69,80 @@ export interface FleetRosterV2 {
|
||||
readonly agents: readonly FleetRosterV2Agent[];
|
||||
}
|
||||
|
||||
export interface SemanticallyValidatedRosterV2Agent extends FleetRosterV2Agent {
|
||||
readonly requestedClass: string;
|
||||
readonly canonicalClass: string;
|
||||
readonly canonicalToolPolicy: string;
|
||||
readonly persona: PersonaResolution;
|
||||
readonly authority: RoleAuthority;
|
||||
}
|
||||
|
||||
export interface SemanticallyValidatedRosterV2 extends Omit<FleetRosterV2, 'agents'> {
|
||||
readonly agents: readonly SemanticallyValidatedRosterV2Agent[];
|
||||
}
|
||||
|
||||
const PROTECTED_TOOL_POLICY_CLASSES = new Set([
|
||||
'merge-gate',
|
||||
'validator',
|
||||
'orchestrator',
|
||||
'team-leader',
|
||||
'interaction',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate filesystem-backed roster semantics after synchronous structural parsing.
|
||||
* Directory scans are batched once and every class must resolve to readable content.
|
||||
*/
|
||||
export async function validateRosterV2Semantics(
|
||||
roster: FleetRosterV2,
|
||||
opts: PersonaDirs = {},
|
||||
): Promise<SemanticallyValidatedRosterV2> {
|
||||
const rolesDir = opts.rolesDir ?? defaultRolesDir(opts.mosaicHome);
|
||||
const overrideDir = opts.overrideDir ?? defaultOverrideDir(opts.mosaicHome);
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
|
||||
const agents: SemanticallyValidatedRosterV2Agent[] = [];
|
||||
for (const agent of roster.agents) {
|
||||
const requestedClass = agent.className;
|
||||
const { canonicalClass } = canonicalizeRoleClass(requestedClass);
|
||||
const canonicalToolPolicy = canonicalizeRoleClass(agent.toolPolicy).canonicalClass;
|
||||
const persona = await resolvePersonaFrom(requestedClass, {
|
||||
rolesDir,
|
||||
overrideDir,
|
||||
base,
|
||||
over,
|
||||
});
|
||||
if (!persona || persona.content.trim() === '') {
|
||||
throw new RosterV2ValidationError(
|
||||
`Roster v2 agent "${agent.name}" class "${requestedClass}" does not resolve to a readable persona.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(PROTECTED_TOOL_POLICY_CLASSES.has(canonicalClass) ||
|
||||
PROTECTED_TOOL_POLICY_CLASSES.has(canonicalToolPolicy)) &&
|
||||
canonicalToolPolicy !== canonicalClass
|
||||
) {
|
||||
throw new RosterV2ValidationError(
|
||||
`Roster v2 agent "${agent.name}" protected class "${canonicalClass}" tool policy must match its canonical class; received "${agent.toolPolicy}".`,
|
||||
);
|
||||
}
|
||||
agents.push(
|
||||
Object.freeze({
|
||||
...agent,
|
||||
requestedClass,
|
||||
canonicalClass,
|
||||
canonicalToolPolicy,
|
||||
persona,
|
||||
authority: authorityForCanonicalClass(canonicalClass),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...roster, agents: Object.freeze(agents) });
|
||||
}
|
||||
|
||||
export class RosterV2ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
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,4 +4,3 @@ export interface AgentSessionHandle {
|
||||
}
|
||||
|
||||
export * from './agent-runtime-provider.js';
|
||||
export * from './connector-lease.dto.js';
|
||||
|
||||
Reference in New Issue
Block a user