Compare commits

...

3 Commits

Author SHA1 Message Date
Jarvis
dcb3f5a2ca test(fleet): validate shipped artifact dispositions
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-14 20:24:50 -05:00
a5e8e55401 feat(fleet): add shared role semantics (#768)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 00:53:47 +00:00
eb4e14ae5c feat(mos): add logical identity connector fencing (#757)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-14 23:33:06 +00:00
46 changed files with 8983 additions and 178 deletions

View File

@@ -21,6 +21,12 @@ import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js'; import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js'; import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.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 { import {
AGENT_RUNTIME_PROVIDER_REGISTRY, AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER, RUNTIME_APPROVAL_VERIFIER,
@@ -46,6 +52,13 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
SkillLoaderService, SkillLoaderService,
DurableSessionRepository, DurableSessionRepository,
DurableSessionService, DurableSessionService,
ConnectorLeaseRepository,
DenyConnectorLeasePolicy,
{
provide: CONNECTOR_LEASE_POLICY,
useExisting: DenyConnectorLeasePolicy,
},
ConnectorLeaseService,
{ {
provide: AGENT_RUNTIME_PROVIDER_REGISTRY, provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry, useFactory: createGatewayRuntimeProviderRegistry,
@@ -78,6 +91,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
SkillLoaderService, SkillLoaderService,
DurableSessionService, DurableSessionService,
RuntimeProviderService, RuntimeProviderService,
ConnectorLeaseService,
AGENT_RUNTIME_PROVIDER_REGISTRY, AGENT_RUNTIME_PROVIDER_REGISTRY,
], ],
}) })

View File

@@ -0,0 +1,341 @@
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,
type ConnectorLeasePolicySubject,
} 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;
let repository: ConnectorLeaseRepository;
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);
repository = moduleRef.get(ConnectorLeaseRepository);
});
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();
});
it('rejects submitted lifecycle scopes that differ from durable authority before policy or mutation', async (): Promise<void> => {
authorize.mockResolvedValue(true);
const heartbeatLease = await service.acquire(
{
logicalAgentId: 'mos',
bindingId: 'operator-chat-heartbeat-scope',
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
{ ...context, correlationId: 'correlation-heartbeat-scope-setup' },
);
const releaseLease = await service.acquire(
{
logicalAgentId: 'mos',
bindingId: 'operator-chat-release-scope',
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
{ ...context, correlationId: 'correlation-release-scope-setup' },
);
const forgedHeartbeat = { ...heartbeatLease, scopes: ['tool.execute'] };
const forgedRelease = { ...releaseLease, scopes: ['tool.execute'] };
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'tool.execute';
});
authorize.mockClear();
await expect(
service.heartbeat(forgedHeartbeat, 30_000, {
...context,
correlationId: 'correlation-heartbeat-scope-forgery',
}),
).rejects.toThrow('Connector authority policy denied');
await expect(
service.release(forgedRelease, {
...context,
correlationId: 'correlation-release-scope-forgery',
}),
).rejects.toThrow('Connector authority policy denied');
expect(authorize).not.toHaveBeenCalled();
const currentHeartbeat = await repository.findCurrent({
identity: heartbeatLease.identity,
bindingId: heartbeatLease.bindingId,
});
const currentRelease = await repository.findCurrent({
identity: releaseLease.identity,
bindingId: releaseLease.bindingId,
});
expect(currentHeartbeat).toMatchObject({
leaseId: heartbeatLease.leaseId,
scopes: ['runtime.send'],
heartbeatAt: heartbeatLease.heartbeatAt,
expiresAt: heartbeatLease.expiresAt,
});
expect(currentRelease).toMatchObject({
leaseId: releaseLease.leaseId,
scopes: ['runtime.send'],
});
expect(currentRelease?.releasedAt).toBeUndefined();
const forgedAudits = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-heartbeat-scope-forgery'));
expect(forgedAudits).toHaveLength(1);
expect(forgedAudits[0]).toMatchObject({
bindingId: heartbeatLease.bindingId,
connectorId: heartbeatLease.connectorId,
event: 'reject',
outcome: 'denied',
reason: 'policy_denied',
});
const forgedReleaseAudits = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-release-scope-forgery'));
expect(forgedReleaseAudits).toHaveLength(1);
expect(forgedReleaseAudits[0]).toMatchObject({
bindingId: releaseLease.bindingId,
connectorId: releaseLease.connectorId,
event: 'reject',
outcome: 'denied',
reason: 'policy_denied',
});
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'runtime.send';
});
await expect(
service.heartbeat(heartbeatLease, 30_000, {
...context,
correlationId: 'correlation-heartbeat-scope-canonical',
}),
).resolves.toMatchObject({ scopes: ['runtime.send'] });
await expect(
service.release(releaseLease, {
...context,
correlationId: 'correlation-release-scope-canonical',
}),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,76 @@
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
connectorLeaseAuditLog,
createDb,
eq,
logicalAgentConnectorLeases,
type DbHandle,
} from '@mosaicstack/db';
import { ConnectorLeaseCoordinator } from '@mosaicstack/agent';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
const hasPostgres = Boolean(process.env['DATABASE_URL']);
const tenantId = `lease-test-${randomUUID()}`;
const identity = { tenantId, logicalAgentId: 'mos' } as const;
describe.skipIf(!hasPostgres)('ConnectorLeaseRepository real PostgreSQL integration', (): void => {
let handle: DbHandle;
beforeAll((): void => {
handle = createDb(process.env['DATABASE_URL']);
});
afterAll(async (): Promise<void> => {
if (!handle) return;
await handle.db
.delete(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.tenantId, tenantId));
await handle.db
.delete(logicalAgentConnectorLeases)
.where(eq(logicalAgentConnectorLeases.tenantId, tenantId));
await handle.close();
});
it('preserves the exclusive CAS fence across a real pool close/reopen', async (): Promise<void> => {
const command = {
identity,
bindingId: 'operator-chat',
scopes: ['runtime.send'],
ttlMs: 60_000,
} as const;
const firstCoordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
const contenders = await Promise.allSettled([
firstCoordinator.acquire({
...command,
connectorId: 'connector-a',
correlationId: 'postgres-acquire-a',
}),
firstCoordinator.acquire({
...command,
connectorId: 'connector-b',
correlationId: 'postgres-acquire-b',
}),
]);
const acquired = contenders.find((result) => result.status === 'fulfilled');
if (!acquired || acquired.status !== 'fulfilled') throw new Error('no lease contender won');
expect(contenders.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
await handle.close();
handle = createDb(process.env['DATABASE_URL']);
const reopened = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
const persisted = await reopened.current({ identity, bindingId: 'operator-chat' });
expect(persisted).toMatchObject({
leaseId: acquired.value.leaseId,
leaseEpoch: '1',
});
const takeover = await reopened.takeover({
...command,
connectorId: 'connector-c',
correlationId: 'postgres-takeover',
expectedEpoch: acquired.value.leaseEpoch,
});
expect(takeover).toMatchObject({ connectorId: 'connector-c', leaseEpoch: '2' });
});
});

View File

@@ -0,0 +1,149 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
connectorLeaseAuditLog,
createPgliteDb,
eq,
runPgliteMigrations,
type DbHandle,
} from '@mosaicstack/db';
import { ConnectorLeaseCoordinator, ConnectorLeaseError } from '@mosaicstack/agent';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
function acquireCommand(connectorId: string, correlationId: string) {
return {
identity,
bindingId: 'operator-chat',
connectorId,
scopes: ['runtime.send', 'tool.execute'],
ttlMs: 60_000,
correlationId,
};
}
describe('ConnectorLeaseRepository PostgreSQL semantics', (): void => {
let dataDir: string;
let handle: DbHandle;
let now: Date;
let coordinator: ConnectorLeaseCoordinator;
beforeEach(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-connector-lease-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
now = new Date('2026-07-14T17:00:00.000Z');
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
now: (): Date => now,
});
});
afterEach(async (): Promise<void> => {
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
it('allows only one concurrent contender to acquire a binding', async (): Promise<void> => {
const outcomes = await Promise.allSettled([
coordinator.acquire(acquireCommand('connector-a', 'correlation-a')),
coordinator.acquire(acquireCommand('connector-b', 'correlation-b')),
]);
expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
const rejected = outcomes.find((result) => result.status === 'rejected');
expect(rejected).toMatchObject({
reason: { code: 'lease_held' } satisfies Partial<ConnectorLeaseError>,
});
});
it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
const results = await Promise.allSettled([
coordinator.takeover({
...acquireCommand('connector-b', 'correlation-b'),
expectedEpoch: acquired.leaseEpoch,
}),
coordinator.takeover({
...acquireCommand('connector-c', 'correlation-c'),
expectedEpoch: acquired.leaseEpoch,
}),
]);
const winner = results.find((result) => result.status === 'fulfilled');
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(winner?.status === 'fulfilled' ? winner.value.leaseEpoch : null).toBe('2');
expect(results.find((result) => result.status === 'rejected')).toMatchObject({
reason: { code: 'cas_mismatch' } satisfies Partial<ConnectorLeaseError>,
});
});
it('heartbeats and releases only the current connector epoch', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
now = new Date('2026-07-14T17:00:30.000Z');
const renewed = await coordinator.heartbeat({
lease: acquired,
ttlMs: 120_000,
correlationId: 'correlation-renew',
});
expect(renewed.expiresAt).toBe('2026-07-14T17:02:30.000Z');
await coordinator.release({ lease: renewed, correlationId: 'correlation-release' });
await expect(
coordinator.heartbeat({
lease: renewed,
ttlMs: 120_000,
correlationId: 'correlation-stale',
}),
).rejects.toMatchObject({ code: 'lease_released' } satisfies Partial<ConnectorLeaseError>);
});
it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
await handle.close();
now = new Date('2026-07-14T17:02:00.000Z');
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
now: (): Date => now,
});
await expect(
coordinator.acquire(acquireCommand('connector-b', 'correlation-plain-acquire')),
).rejects.toMatchObject({ code: 'takeover_required' } satisfies Partial<ConnectorLeaseError>);
const recovered = await coordinator.takeover({
...acquireCommand('connector-b', 'correlation-takeover'),
expectedEpoch: acquired.leaseEpoch,
});
expect(recovered).toMatchObject({ connectorId: 'connector-b', leaseEpoch: '2' });
});
it('writes credential-safe lifecycle and rejection audit records', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
await coordinator.heartbeat({
lease: acquired,
ttlMs: 60_000,
correlationId: 'correlation-renew',
});
await expect(
coordinator.acquire(acquireCommand('connector-b', 'correlation-reject')),
).rejects.toBeInstanceOf(ConnectorLeaseError);
const rows = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.tenantId, identity.tenantId));
expect(rows.map((row) => row.event)).toEqual(
expect.arrayContaining(['acquire', 'renew', 'reject']),
);
const serialized = JSON.stringify(rows, (_key: string, value: unknown): unknown =>
typeof value === 'bigint' ? value.toString(10) : value,
);
expect(serialized).not.toContain('tool.execute');
expect(serialized).not.toContain('runtime.send');
expect(serialized).not.toMatch(/token|secret|credential/i);
});
});

View File

@@ -0,0 +1,354 @@
import { Inject, Injectable } from '@nestjs/common';
import {
and,
connectorLeaseAuditLog,
eq,
gt,
isNull,
logicalAgentConnectorLeases,
sql,
type Db,
} from '@mosaicstack/db';
import { ConnectorLeaseError } from '@mosaicstack/agent';
import type {
ConnectorLease,
ConnectorLeaseAcquireMutation,
ConnectorLeaseAuditEvent,
ConnectorLeaseHeartbeatMutation,
ConnectorLeaseRejectReason,
ConnectorLeaseReleaseMutation,
ConnectorLeaseStore,
ConnectorLeaseTakeoverMutation,
LogicalAgentBinding,
} from '@mosaicstack/types';
import { DB } from '../database/database.module.js';
interface SuccessfulMutation {
readonly ok: true;
readonly lease: ConnectorLease;
}
interface FailedMutation {
readonly ok: false;
readonly reason: ConnectorLeaseRejectReason;
}
type MutationResult = SuccessfulMutation | FailedMutation;
@Injectable()
export class ConnectorLeaseRepository implements ConnectorLeaseStore {
constructor(@Inject(DB) private readonly db: Db) {}
async acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const inserted = await tx
.insert(logicalAgentConnectorLeases)
.values({
leaseId: input.leaseId,
tenantId: input.identity.tenantId,
logicalAgentId: input.identity.logicalAgentId,
bindingId: input.bindingId,
connectorId: input.connectorId,
scopes: [...input.scopes],
leaseEpoch: 1n,
acquiredAt: new Date(input.now),
heartbeatAt: new Date(input.now),
expiresAt: new Date(input.expiresAt),
updatedAt: new Date(input.now),
})
.onConflictDoNothing()
.returning();
const row = inserted[0];
if (row) {
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'acquire'));
return { ok: true, lease };
}
const current = await findRow(tx, input);
if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
const reason: ConnectorLeaseRejectReason =
current && (current.releasedAt || current.expiresAt <= new Date(input.now))
? 'takeover_required'
: 'lease_held';
await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason));
return { ok: false, reason };
},
);
return unwrap(result);
}
async takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const current = await findRow(tx, input);
if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) {
await insertAudit(
tx,
rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'),
);
return { ok: false, reason: 'cas_mismatch' };
}
if (current.expiresAt <= new Date(input.now) && !current.releasedAt) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
const updated = await tx
.update(logicalAgentConnectorLeases)
.set({
leaseId: input.leaseId,
connectorId: input.connectorId,
scopes: [...input.scopes],
leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`,
acquiredAt: new Date(input.now),
heartbeatAt: new Date(input.now),
expiresAt: new Date(input.expiresAt),
releasedAt: null,
updatedAt: new Date(input.now),
})
.where(
and(
bindingPredicate(input),
eq(logicalAgentConnectorLeases.leaseId, current.leaseId),
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)),
),
)
.returning();
const row = updated[0];
if (!row) {
await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch'));
return { ok: false, reason: 'cas_mismatch' };
}
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'takeover'));
return { ok: true, lease };
},
);
return unwrap(result);
}
async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const updated = await tx
.update(logicalAgentConnectorLeases)
.set({
heartbeatAt: new Date(input.now),
expiresAt: new Date(input.expiresAt),
updatedAt: new Date(input.now),
})
.where(
and(
bindingPredicate(input.lease),
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
isNull(logicalAgentConnectorLeases.releasedAt),
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
),
)
.returning();
const row = updated[0];
if (row) {
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'renew'));
return { ok: true, lease };
}
const current = await findRow(tx, input.lease);
const reason = classifyAuthorityFailure(
current ? toLease(current) : null,
input.lease,
input.now,
);
if (reason === 'lease_expired' && current) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
await insertAudit(
tx,
rejectionAudit(
{ ...input.lease, correlationId: input.correlationId, now: input.now },
current ? toLease(current) : null,
reason,
),
);
return { ok: false, reason };
},
);
return unwrap(result);
}
async release(input: ConnectorLeaseReleaseMutation): Promise<void> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const updated = await tx
.update(logicalAgentConnectorLeases)
.set({
releasedAt: new Date(input.now),
expiresAt: new Date(input.now),
updatedAt: new Date(input.now),
})
.where(
and(
bindingPredicate(input.lease),
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
isNull(logicalAgentConnectorLeases.releasedAt),
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
),
)
.returning();
const row = updated[0];
if (row) {
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'release'));
return { ok: true, lease };
}
const current = await findRow(tx, input.lease);
const reason = classifyAuthorityFailure(
current ? toLease(current) : null,
input.lease,
input.now,
);
if (reason === 'lease_expired' && current) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
await insertAudit(
tx,
rejectionAudit(
{ ...input.lease, correlationId: input.correlationId, now: input.now },
current ? toLease(current) : null,
reason,
),
);
return { ok: false, reason };
},
);
unwrap(result);
}
async findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
const row = await findRow(this.db, binding);
return row ? toLease(row) : null;
}
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
await insertAudit(this.db, event);
}
}
function unwrap(result: MutationResult): ConnectorLease {
if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason));
return result.lease;
}
function safeErrorMessage(reason: ConnectorLeaseRejectReason): string {
return `Connector lease mutation denied: ${reason}`;
}
function bindingPredicate(binding: LogicalAgentBinding) {
return and(
eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId),
eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId),
eq(logicalAgentConnectorLeases.bindingId, binding.bindingId),
);
}
async function findRow(
db: Pick<Db, 'select'>,
binding: LogicalAgentBinding,
): Promise<typeof logicalAgentConnectorLeases.$inferSelect | null> {
const rows = await db
.select()
.from(logicalAgentConnectorLeases)
.where(bindingPredicate(binding))
.limit(1);
return rows[0] ?? null;
}
function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease {
return Object.freeze({
identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }),
bindingId: row.bindingId,
leaseId: row.leaseId,
connectorId: row.connectorId,
scopes: Object.freeze([...row.scopes]),
leaseEpoch: row.leaseEpoch.toString(10),
acquiredAt: row.acquiredAt.toISOString(),
heartbeatAt: row.heartbeatAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}),
});
}
function classifyAuthorityFailure(
current: ConnectorLease | null,
claimed: ConnectorLease,
now: string,
): ConnectorLeaseRejectReason {
if (!current) return 'lease_missing';
if (current.releasedAt) return 'lease_released';
if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired';
if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch';
return 'connector_mismatch';
}
function lifecycleAudit(
input: { readonly correlationId: string; readonly now: string },
lease: ConnectorLease,
event: Exclude<ConnectorLeaseAuditEvent['event'], 'reject'>,
): ConnectorLeaseAuditEvent {
return {
identity: lease.identity,
bindingId: lease.bindingId,
connectorId: lease.connectorId,
leaseId: lease.leaseId,
leaseEpoch: lease.leaseEpoch,
event,
outcome: 'succeeded',
correlationId: input.correlationId,
occurredAt: input.now,
};
}
function rejectionAudit(
input: {
readonly identity: ConnectorLease['identity'];
readonly bindingId: string;
readonly connectorId: string;
readonly correlationId: string;
readonly now: string;
},
current: ConnectorLease | null,
reason: ConnectorLeaseRejectReason,
): ConnectorLeaseAuditEvent {
return {
identity: input.identity,
bindingId: input.bindingId,
connectorId: input.connectorId,
event: 'reject',
outcome: 'denied',
correlationId: input.correlationId,
occurredAt: input.now,
...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}),
reason,
};
}
async function insertAudit(db: Pick<Db, 'insert'>, event: ConnectorLeaseAuditEvent): Promise<void> {
await db.insert(connectorLeaseAuditLog).values({
tenantId: event.identity.tenantId,
logicalAgentId: event.identity.logicalAgentId,
bindingId: event.bindingId,
connectorId: event.connectorId,
...(event.leaseId ? { leaseId: event.leaseId } : {}),
...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}),
event: event.event,
outcome: event.outcome,
...(event.reason ? { reason: event.reason } : {}),
correlationId: event.correlationId,
occurredAt: new Date(event.occurredAt),
});
}

View File

@@ -0,0 +1,285 @@
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);
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
await this.assertPolicy('lease.heartbeat', durableLease, context, durableLease.scopes, ttlMs);
return this.coordinator.heartbeat({
lease: durableLease,
ttlMs,
correlationId: this.correlation(context),
});
}
async release(lease: ConnectorLease, context: ConnectorLeaseRequestContext): Promise<void> {
const normalizedLease = normalizeConnectorLease(lease);
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
await this.assertPolicy('lease.release', durableLease, context, durableLease.scopes, null);
await this.coordinator.release({
lease: durableLease,
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 durableLifecycleLease(
submittedLease: ConnectorLease,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease> {
await this.assertTenant(submittedLease, context);
const durableLease = await this.coordinator.current(submittedLease);
if (!durableLease || !hasSameLifecycleAuthority(submittedLease, durableLease)) {
await this.recordPolicyDenial(durableLease ?? submittedLease, context);
throw new ForbiddenException('Connector authority policy denied');
}
return durableLease;
}
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);
}
}
function hasSameLifecycleAuthority(
submittedLease: ConnectorLease,
durableLease: ConnectorLease,
): boolean {
return (
submittedLease.identity.tenantId === durableLease.identity.tenantId &&
submittedLease.identity.logicalAgentId === durableLease.identity.logicalAgentId &&
submittedLease.bindingId === durableLease.bindingId &&
submittedLease.leaseId === durableLease.leaseId &&
submittedLease.connectorId === durableLease.connectorId &&
submittedLease.leaseEpoch === durableLease.leaseEpoch &&
submittedLease.scopes.length === durableLease.scopes.length &&
submittedLease.scopes.every((scope: string, index: number): boolean => {
return scope === durableLease.scopes[index];
})
);
}

View File

@@ -284,6 +284,37 @@ 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 ## Architecture
### High-Level System Diagram ### High-Level System Diagram

View File

@@ -56,3 +56,5 @@
- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies. - [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) - [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) - [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)

View File

@@ -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 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. > the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | | id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | | ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| FCM-M0-001 | in-progress | Publish normative PRD requirements/acceptance criteria, this M0M5 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-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0M5 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 | 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-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 | 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-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-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-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-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-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-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-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-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-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 | | 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 ## Thin-core prompt diet (#528) — feat/contract-thin-core

View File

@@ -0,0 +1,49 @@
# Mos Runtime Portability M1 — Logical Identity and Fencing
## Boundary
M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The normalized identity is:
```text
(tenant_id, logical_agent_id, binding_id)
```
`logical_agent_id` is a server-owned stable identifier. A connector is a replaceable holder of a lease for one binding; it is not the agent identity.
## Durable lease model
PostgreSQL table `logical_agent_connector_leases` has one unique row per identity/binding tuple. The current row records:
- an opaque lease UUID;
- connector ID and normalized allowed scopes;
- a positive decimal fencing epoch stored as PostgreSQL `bigint`;
- acquired, heartbeat, expiry, release, and update timestamps.
Initial acquisition is insert-only. An existing active row causes `lease_held`. An expired or released row causes `takeover_required`; ordinary acquisition cannot recover it. Authorized takeover uses compare-and-swap against the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch.
The companion `connector_lease_audit_log` is append-only metadata. It stores lifecycle event, outcome/reason, identity/binding/connector, epoch, correlation ID, and timestamp. It deliberately excludes scopes, grant objects, payloads, approval references, tokens, and credentials.
## Execution grants
`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. Defense-in-depth caps leases at 5 minutes and grants at 30 seconds by default; constructor options may tighten these limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, and epoch.
Validation occurs immediately before adapter invocation and rereads PostgreSQL. The adapter receives only `ConnectorExecutionContext`; harness-native schemas remain behind the adapter. Validation denies:
- grants not minted by the current gateway process (including cloned/forged objects);
- expired grants or leases;
- released leases;
- stale epochs or replaced connector/lease UUIDs;
- missing/cross-tenant/cross-agent/cross-binding leases;
- scopes not authorized by both grant and current lease.
A gateway restart intentionally invalidates process-local grants. The durable lease and epoch survive, and a fresh grant may be issued only after current-lease and gateway-policy validation.
## Concurrency and side-effect rule
The database CAS determines the sole current holder. A successful takeover makes every old-epoch validation fail. Connector adapters must consume and propagate the normalized lease epoch/context so downstream effect boundaries can also fence races that occur after gateway validation.
M1 does not provide exactly-once receipts or a side-effect journal. Those remain later #754 work; callers must not infer exactly-once delivery from lease fencing.
## Extension boundary
`ConnectorLeaseService` is the gateway-owned policy surface. Every policy decision receives the normalized requested scopes and TTL (or explicit `null` where no TTL applies), so a concrete policy can enforce least privilege and duration limits. Its production default policy denies every lease/grant operation until a server-configured connector policy is supplied. No M1 HTTP endpoint accepts caller-controlled tenant or logical identity, and no concrete Claude/Pi/Codex adapter or channel cutover is included.

View File

@@ -7,7 +7,8 @@ The v2 compiler may not silently accept an unresolved class. Before M1 exits, ev
below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or
retired with a replacement/deprecation note. Class resolution must use the existing retired with a replacement/deprecation note. Class resolution must use the existing
profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a
parallel resolver. parallel resolver. The current executable implementation and per-artifact outcomes are recorded in
[the disposition evidence](./migration/example-profile-disposition.md).
## Examples ## Examples

View 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.

View File

@@ -0,0 +1,52 @@
# Executable Fleet Example, Profile, and Service-Preset Dispositions
**Issue:** #758 · **Card:** FCM-M1-003 · **Status:** M1 executable disposition evidence
This document records the executable disposition for every currently shipped fleet YAML artifact.
The authoritative baseline classification remains the
[legacy inventory](../LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md). The executable guard is
`packages/mosaic/src/fleet/example-profile-dispositions.ts`; its test fails if a shipped YAML
artifact is added, removed, or left without one of the dispositions below.
## Disposition rules
- **Explicit v1 fixture:** the artifact is loaded through the existing v1 roster parser and must
declare `version: 1`. It remains a compatibility fixture; it is not silently treated as a v2
roster or given inferred aliases.
- **Canonical profile:** the artifact is loaded through `loadProfiles`, which uses the shared
baseline-plus-`roles.local` persona resolver and rejects unreadable or unresolved classes.
- **Canonical service policy:** the artifact is loaded through the operator-interaction service
policy reader and provisioned with a generic supplied identity. It validates its runtime, model,
reasoning, and legacy tool-policy compatibility without hardcoding a product identity.
No artifact is retired in this card. A later retirement requires both a replacement link and a
visible deprecation note; the executable guard must then record the new disposition before the
artifact can be removed.
## Shipped artifacts
| Artifact | Disposition | Executable path | Compatibility notes |
| ------------------------------------ | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------ |
| `examples/coding.yaml` | Explicit v1 fixture | v1 roster parser | Retains approved `implementer` and `reviewer` compatibility inputs. |
| `examples/general.yaml` | Explicit v1 fixture | v1 roster parser | Retains unresolved `worker` without an inferred canonical role. |
| `examples/hybrid.yaml` | Explicit v1 fixture | v1 roster parser | Retains `implementer`, `reviewer`, and resolver-dependent `researcher`. |
| `examples/local-canary.yaml` | Explicit v1 fixture | v1 roster parser | Retains the local-tmux canary topology. |
| `examples/minimal.yaml` | Explicit v1 fixture | v1 roster parser | Retains `canary` without an inferred canonical role. |
| `examples/operator-interaction.yaml` | Explicit v1 fixture | v1 roster parser | Keeps Tess only as an example instance name; `operator-interaction` remains compatibility input. |
| `examples/research.yaml` | Explicit v1 fixture | v1 roster parser | Retains resolver-dependent `researcher` and `analyst`. |
| `profiles/business.yaml` | Canonical profile | shared profile/persona resolver | Every referenced business class must resolve to a readable contract. |
| `profiles/marketing.yaml` | Canonical profile | shared profile/persona resolver | Every referenced marketing class must resolve to a readable contract. |
| `profiles/personal-assistant.yaml` | Canonical profile | shared profile/persona resolver | No interaction equivalence is inferred. |
| `profiles/research.yaml` | Canonical profile | shared profile/persona resolver | Every research class must resolve to a readable contract. |
| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. |
| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. |
## Running the guard
```bash
pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts
```
The guard is intentionally limited to shipped assets and validation. It does not generate
environment files, mutate a roster, reconcile a fleet, migrate an installed roster, or launch an
agent.

View 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.

View 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.

View File

@@ -81,6 +81,35 @@ agents:
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` | | `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch | | `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 ## Fail-closed boundary
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed, Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,

View File

@@ -0,0 +1,43 @@
# Mos Connector Lease Operations — M1
## Operational status
M1 installs the durable schema and gateway policy/adapter boundary. It does **not** activate a connector, expose a lease administration endpoint, or cut over a channel. The default gateway connector-lease policy is deny-all until a later work package supplies an authorized server-side policy and concrete adapter.
## Events to monitor
Use correlation IDs to follow `connector_lease_audit_log` events:
| Event | Meaning |
| ---------- | --------------------------------------------------------------------- |
| `acquire` | First holder inserted for an unused binding |
| `renew` | Current holder heartbeat extended the TTL |
| `takeover` | Authorized CAS replaced the holder and incremented epoch |
| `release` | Current holder explicitly relinquished authority |
| `expiry` | An expired current lease was observed |
| `reject` | Policy, CAS, expiry, scope, or fencing validation denied an operation |
Audit data is metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output.
## Incident checks
For suspected duplicate/stale connector effects:
1. Correlate the attempted operation with its `reject`, `takeover`, or `expiry` event.
2. Compare the current row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context.
3. Treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative. Do not retry it as the old holder.
4. Recovery uses the authorized takeover path with the observed expected epoch. Ordinary acquire is intentionally rejected for expired/released rows.
5. If an external effect may already have happened, preserve evidence and do not assume lease fencing provides exactly-once replay safety.
## Migration and rollback safety
Migration `0016_salty_morlocks.sql` is additive: it creates two new tables and indexes without modifying existing authorization/session tables. Before rollout, normal database backup and migration verification still apply. Rolling application code back leaves unused additive tables in place; dropping tables is not part of automated rollback because it would destroy lease/audit evidence.
## Security constraints
- Tenant comes from authenticated gateway context, never a connector request field.
- Logical agent, binding, connector, and scope identifiers use normalized constrained forms.
- Takeover requires explicit gateway policy authorization and an expected epoch.
- Default defense-in-depth TTL caps are 5 minutes for leases and 30 seconds for grants; policy may enforce stricter limits.
- Validation and rejection audit complete before adapter side effects.
- Existing authz and exact-action approval controls remain additional required gates; a valid connector lease does not bypass them.

View File

@@ -0,0 +1,148 @@
# 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.
## Durable lifecycle-authority remediation (2026-07-14)
- Independent review found that heartbeat and release authorized the submitted lease, allowing forged lifecycle scope data to influence policy before the durable row was consulted.
- Remediation: lifecycle operations tenant-check the submitted lease, load the durable current lease, compare tenant, logical agent, binding, lease UUID, connector, epoch, and canonical ordered scopes, audit and deny any absent/mismatched authority, then run policy and coordinator lifecycle calls with the durable lease. Coordinator CAS/fencing checks remain unchanged.
- Adversarial gateway integration coverage proves forged `tool.execute` scopes on a durable `runtime.send` lease deny before policy or mutation, preserve heartbeat/release state, and write a denial audit for both lifecycle actions; canonical heartbeat and release remain accepted.
- Verification: focused types 6/6; agent 10/10; gateway PGlite integration/repository 9/9 with one `DATABASE_URL`-gated PostgreSQL test skipped; Drizzle check passed; root typecheck 42/42; lint 23/23; format check passed; root test 42/42 (gateway 628 passed / 12 environment-gated skipped).
- Pending: commit, queue-guard, force-with-lease push, then independent DB/code/security rereview and CI. Do not merge or close #755.

View File

@@ -0,0 +1,148 @@
# 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.
### Post-PR fail-closed remediation
Independent rereview found resolver fail-open edges that the original green PR head did not cover. The
remediation remained uncommitted until every finding was reproduced red-first and the same reviewer
approved the complete two-file delta.
Final regression evidence:
```text
persona resolver: 47/47
focused affected suites: 6 files / 138 tests
root-container resolver suites: 86/86
full canonical run: 42/42 Turbo tasks; Mosaic 50 files / 733 tests
```
DB migration, typecheck, lint, Prettier, and `git diff --check` also passed. Coverage now proves:
- unreadable, unscannable, direct-dangling, ancestor-dangling, and literal `..` traversal override paths
fail closed across async, sync, listing, and status APIs;
- genuinely missing override directories still permit baseline fallback;
- cached missing scans are revalidated before fallback;
- marker-defined identity and domain metadata are revalidated on the second read;
- `LIBRARY.md` rows and incidental later markers cannot define, shadow, or advertise personas.
Exact-head pipeline `1819` passed for rebased head `4d990eee…`, but the independent reviewer-of-record
returned **REQUEST CHANGES** after reproducing three additional edge failures: a canonical filename could
inherit protected authority despite a conflicting explicit first marker, cached `scanned` absence could
miss an override created before baseline fallback, and inherited plain-object names such as `constructor`
could corrupt alias/authority lookup. Merge remained held.
Each failure was reproduced red-first in the persona suite (4 failing assertions), then remediated without
expanding card scope. Explicit first markers now own identity and filename fallback applies only to
markerless contracts; every second read rejects a newly introduced conflicting marker regardless of
cached classification; cached async resolution re-scans the override layer immediately before every
baseline fallback; alias and authority registries require own-property matches. Current uncommitted
evidence is persona **52/52**, focused affected suites **6 files / 143 tests**, and full Mosaic package
**50 files / 738 tests**, plus typecheck, lint, Prettier, and `git diff --check`. Independent
finding-specific rereview **APPROVED** the complete uncommitted three-file remediation after direct
adversarial reproduction of all three findings and the follow-up markerless TOCTOU. All post-commit
exact-head gates remain required.
## 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.

View File

@@ -0,0 +1,37 @@
# FCM-M1-003 — Executable example/profile/service-preset dispositions
- **Task / issue:** FCM-M1-003 / #758
- **Branch / base:** `test/758-example-profile-dispositions` from `origin/main` `a5e8e554012f27898e035d2882a8e47e1a02fe97`
- **Objective:** Make every artifact in the M0 legacy disposition inventory executable evidence: it must validate canonically, be explicitly retained as a v1 fixture, or be retired with a replacement/deprecation link.
- **Scope:** Validation and explicit version/retirement metadata only for shipped examples, profiles, and the operator-interaction service preset. Reuse the central resolver and existing v2 roster compiler.
- **Out of scope:** Generated environment boundaries, CRUD, reconciliation/apply, migration, live fleet mutation, and `docs/TASKS.md`.
- **Budget:** 20K card allocation; use focused package tests before full package validation.
## Plan
1. Inventory exact shipped artifacts and existing compiler/resolver/profile tests.
2. Add failing behavior tests covering all listed artifacts and their documented disposition.
3. Implement minimal declarative fixture/disposition validation; do not add a role/class resolver.
4. Run focused and package quality gates; obtain independent code and security review.
5. Commit, queue-guard, push, open one `main` PR with `Refs #758`.
## Progress
- Intake complete: verified no branch, worktree, or open PR for this card before creating this isolated worktree.
- Requirements read: FCM PRD, FCM-M1-003 task row, M0 disposition inventory, delivery/QA/documentation guides.
- TDD: RED recorded with `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` failing because the new module did not exist; GREEN recorded after the minimal guard implementation. The focused suite now has 4 passing tests, including undeclared-artifact and missing-explicit-v1-version denials.
- Independent review: initial code review found the service policy path was hardcoded; remediation iterates declared `canonical-service-policy` artifacts. Exact-head code review approved and exact-head security review found no issues.
## Risks / decisions
- The M0 inventory permits unresolved legacy roles only when explicitly v1-versioned or retired. Do not infer aliases beyond the three approved by FCM-M1-002.
- `docs/TASKS.md` is orchestrator-owned and will not be edited.
## Verification evidence
- Focused TDD guard: `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` — PASS (4 tests).
- Full package suite: `pnpm --filter @mosaicstack/mosaic test` — PASS (51 files, 742 tests).
- Static gates: `pnpm --filter @mosaicstack/mosaic typecheck`, `pnpm --filter @mosaicstack/mosaic lint`, and `pnpm format:check` — PASS.
- Diff gate: `git diff --check` — PASS.
- Exact-head reviews: `codex-code-review.sh --uncommitted` — APPROVE; `codex-security-review.sh --uncommitted` — no findings.
- Delivery: committed as `9a9ad1a`, pushed after `ci-queue-wait.sh --purpose push`, and opened PR [#770](https://git.mosaicstack.dev/mosaicstack/stack/pulls/770) to `main` with `Refs #758`. `pr-ci-wait.sh -n 770` reported terminal-green Woodpecker pipeline [#1823](https://ci.mosaicstack.dev/repos/47/pipeline/1823/1).

View File

@@ -43,3 +43,4 @@
| TESS-M5-002 | done | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | coder3 | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001. **Mos-DISPATCHED to coder3 2026-07-13** ("M4 complete; advancing to M5") — dispatched AHEAD of TESS-M4-V passing; the M4-V-status-vs-#710-CLOSED dependency reconciliation is pending Mos ruling (tracked, not orchestrator-decided). **DELIVERED as PR #742** — 4 new files docs/tess/M5-MIGRATION-{INVENTORY,CUTOVER,ROLLBACK,RETENTION-DEPRECATION}.md, base main b7b0f508, frozen head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd. Docs-only; tracking-control trio (MISSION-MANIFEST/TASKS/VERIFICATION-MATRIX) UNTOUCHED; command-authz byte-identical a9f829e7; no live creds. **CI pipeline 1773 SUCCESS** (repo 47, refs/pull/742/head, commit==head). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd (Gitea comment 17108)** — evidence claims verified to trace to landed Hermes adapter / capability matrix, gateway registry/reachability, operator-memory scope path, Mos coordination boundary; docs do NOT over-claim transcript/profile import, schema migration, unsupported-capability enablement, production cutover, or deprecation completion. Head independently verified UNMOVED at b5e9d0e528a5 post-ROR (live Gitea), base main, mergeable=true. **#742 MERGED by Mos → main 5789711e. Deliverable docs LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** | | TESS-M5-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-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 | | 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. |

View File

@@ -0,0 +1,261 @@
import { describe, expect, it, vi } from 'vitest';
import type {
ConnectorExecutionContext,
ConnectorExecutionGrant,
ConnectorLease,
ConnectorLeaseAuditEvent,
ConnectorLeaseStore,
FencedConnectorAdapter,
LogicalAgentBinding,
} from '@mosaicstack/types';
import {
ConnectorLeaseCoordinator,
MAX_CONNECTOR_GRANT_TTL_MS,
MAX_CONNECTOR_LEASE_TTL_MS,
} from './connector-lease.js';
import type { ConnectorLeaseError } from './connector-lease.js';
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
const binding: LogicalAgentBinding = { identity, bindingId: 'operator-chat' };
const activeLease: ConnectorLease = {
...binding,
leaseId: '00000000-0000-4000-8000-000000000001',
connectorId: 'connector-a',
scopes: ['runtime.send', 'tool.execute'],
leaseEpoch: '3',
acquiredAt: '2026-07-14T17:00:00.000Z',
heartbeatAt: '2026-07-14T17:00:00.000Z',
expiresAt: '2026-07-14T17:10:00.000Z',
};
class FakeLeaseStore implements ConnectorLeaseStore {
lease: ConnectorLease | null = activeLease;
readonly audits: ConnectorLeaseAuditEvent[] = [];
async acquire(): Promise<ConnectorLease> {
if (!this.lease) throw new Error('fixture has no lease');
return this.lease;
}
async takeover(): Promise<ConnectorLease> {
if (!this.lease) throw new Error('fixture has no lease');
return this.lease;
}
async heartbeat(): Promise<ConnectorLease> {
if (!this.lease) throw new Error('fixture has no lease');
return this.lease;
}
async release(): Promise<void> {}
async findCurrent(): Promise<ConnectorLease | null> {
return this.lease;
}
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
this.audits.push(event);
}
}
describe('ConnectorLeaseCoordinator fencing', (): void => {
it('validates a server-minted grant immediately before invoking an adapter side effect', async (): Promise<void> => {
const store = new FakeLeaseStore();
const coordinator = new ConnectorLeaseCoordinator(store, {
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
});
const grant = await coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: 30_000,
correlationId: 'correlation-1',
});
const execute = vi.fn(async (_input: string, context: ConnectorExecutionContext) => context);
const adapter: FencedConnectorAdapter<string, ConnectorExecutionContext> = { execute };
const context = await coordinator.executeGrant(grant, 'runtime.send', 'hello', adapter);
expect(execute).toHaveBeenCalledOnce();
expect(context).toMatchObject({
identity,
bindingId: 'operator-chat',
connectorId: 'connector-a',
leaseEpoch: '3',
scopes: ['runtime.send'],
});
});
it('caps grant expiry to the durable current lease instead of submitted metadata', async (): Promise<void> => {
const store = new FakeLeaseStore();
store.lease = { ...activeLease, expiresAt: '2026-07-14T17:01:05.000Z' };
const coordinator = new ConnectorLeaseCoordinator(store, {
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
});
const grant = await coordinator.issueGrant({
lease: { ...activeLease, expiresAt: '2026-07-14T18:00:00.000Z' },
scopes: ['runtime.send'],
ttlMs: 30_000,
correlationId: 'correlation-durable-expiry',
});
expect(grant.expiresAt).toBe('2026-07-14T17:01:05.000Z');
});
it.each([
['forged clone', (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ ...grant })],
[
'cross-tenant clone',
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
...grant,
identity: { ...grant.identity, tenantId: 'tenant-b' },
}),
],
[
'cross-agent clone',
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
...grant,
identity: { ...grant.identity, logicalAgentId: 'other-agent' },
}),
],
[
'cross-binding clone',
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
...grant,
bindingId: 'other-binding',
}),
],
[
'cross-connector clone',
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
...grant,
connectorId: 'connector-b',
}),
],
])('denies and audits a %s before adapter invocation', async (_label, forge): Promise<void> => {
const store = new FakeLeaseStore();
const coordinator = new ConnectorLeaseCoordinator(store, {
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
});
const grant = await coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: 30_000,
correlationId: 'correlation-forged',
});
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
await expect(
coordinator.executeGrant(forge(grant), 'runtime.send', undefined, adapter),
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
expect(adapter.execute).not.toHaveBeenCalled();
expect(store.audits.at(-1)).toMatchObject({
event: 'reject',
outcome: 'denied',
reason: 'forged_grant',
correlationId: 'correlation-forged',
});
});
it('denies malformed forged grants with sanitized audit metadata', async (): Promise<void> => {
const store = new FakeLeaseStore();
const coordinator = new ConnectorLeaseCoordinator(store, {
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
});
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
await expect(
coordinator.executeGrant(
// @ts-expect-error Deliberately exercise malformed runtime input at the trust boundary.
{},
'runtime.send',
undefined,
adapter,
),
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
expect(adapter.execute).not.toHaveBeenCalled();
expect(store.audits).toContainEqual(
expect.objectContaining({
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
bindingId: 'untrusted',
connectorId: 'untrusted',
correlationId: 'untrusted',
reason: 'forged_grant',
}),
);
});
it('rejects lease and grant TTLs above server-side safety caps', async (): Promise<void> => {
const store = new FakeLeaseStore();
const coordinator = new ConnectorLeaseCoordinator(store, {
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
});
expect(
() =>
new ConnectorLeaseCoordinator(store, {
maxLeaseTtlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
}),
).toThrow(/no greater than/);
await expect(
coordinator.acquire({
identity,
bindingId: 'operator-chat',
connectorId: 'connector-a',
scopes: ['runtime.send'],
ttlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
correlationId: 'correlation-ttl',
}),
).rejects.toThrow(/no greater than/);
await expect(
coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: MAX_CONNECTOR_GRANT_TTL_MS + 1,
correlationId: 'correlation-ttl',
}),
).rejects.toThrow(/no greater than/);
});
it('denies stale epoch, expired grant, and unauthorized scope before side effects', async (): Promise<void> => {
let now = new Date('2026-07-14T17:01:00.000Z');
const store = new FakeLeaseStore();
const coordinator = new ConnectorLeaseCoordinator(store, { now: (): Date => now });
const stale = await coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: 30_000,
correlationId: 'correlation-stale',
});
store.lease = { ...activeLease, leaseEpoch: '4', connectorId: 'connector-b' };
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
await expect(
coordinator.executeGrant(stale, 'runtime.send', undefined, adapter),
).rejects.toMatchObject({ code: 'stale_epoch' } satisfies Partial<ConnectorLeaseError>);
store.lease = activeLease;
const expiring = await coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: 1_000,
correlationId: 'correlation-expired',
});
now = new Date('2026-07-14T17:01:02.000Z');
await expect(
coordinator.executeGrant(expiring, 'runtime.send', undefined, adapter),
).rejects.toMatchObject({ code: 'grant_expired' } satisfies Partial<ConnectorLeaseError>);
now = new Date('2026-07-14T17:01:00.000Z');
const scoped = await coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: 30_000,
correlationId: 'correlation-scope',
});
await expect(
coordinator.executeGrant(scoped, 'tool.execute', undefined, adapter),
).rejects.toMatchObject({ code: 'scope_denied' } satisfies Partial<ConnectorLeaseError>);
expect(adapter.execute).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,381 @@
import { randomUUID } from 'node:crypto';
import {
normalizeConnectorId,
normalizeConnectorScope,
normalizeConnectorScopes,
normalizeCorrelationId,
normalizeLeaseEpoch,
normalizeLogicalAgentIdentity,
normalizeLogicalBindingId,
type AcquireConnectorLeaseInput,
type ConnectorExecutionContext,
type ConnectorExecutionGrant,
type ConnectorLease,
type ConnectorLeaseAuditEvent,
type ConnectorLeaseRejectReason,
type ConnectorLeaseStore,
type FencedConnectorAdapter,
type HeartbeatConnectorLeaseInput,
type IssueConnectorExecutionGrantInput,
type LogicalAgentBinding,
type ReleaseConnectorLeaseInput,
type TakeoverConnectorLeaseInput,
} from '@mosaicstack/types';
export const MAX_CONNECTOR_LEASE_TTL_MS = 5 * 60 * 1000;
export const MAX_CONNECTOR_GRANT_TTL_MS = 30 * 1000;
export interface ConnectorLeaseCoordinatorOptions {
readonly now?: () => Date;
readonly maxLeaseTtlMs?: number;
readonly maxGrantTtlMs?: number;
}
export class ConnectorLeaseError extends Error {
constructor(
readonly code: ConnectorLeaseRejectReason,
message: string,
) {
super(message);
this.name = ConnectorLeaseError.name;
}
}
/**
* Runtime-neutral lease coordinator. Durable CAS lives in the store adapter;
* grant provenance remains process-local so a restart fails closed and mints
* fresh grants from the durable current lease.
*/
export class ConnectorLeaseCoordinator {
private readonly issuedGrants = new WeakSet<object>();
private readonly now: () => Date;
private readonly maxLeaseTtlMs: number;
private readonly maxGrantTtlMs: number;
constructor(
private readonly store: ConnectorLeaseStore,
options: ConnectorLeaseCoordinatorOptions = {},
) {
this.now = options.now ?? (() => new Date());
this.maxLeaseTtlMs = normalizeTtlLimit(
options.maxLeaseTtlMs ?? MAX_CONNECTOR_LEASE_TTL_MS,
MAX_CONNECTOR_LEASE_TTL_MS,
'lease',
);
this.maxGrantTtlMs = normalizeTtlLimit(
options.maxGrantTtlMs ?? MAX_CONNECTOR_GRANT_TTL_MS,
MAX_CONNECTOR_GRANT_TTL_MS,
'grant',
);
}
async acquire(input: AcquireConnectorLeaseInput): Promise<ConnectorLease> {
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
const now = this.now();
return this.store.acquire({
...command,
leaseId: randomUUID(),
now: now.toISOString(),
expiresAt: expiresAt(now, command.ttlMs),
});
}
async takeover(input: TakeoverConnectorLeaseInput): Promise<ConnectorLease> {
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
const now = this.now();
return this.store.takeover({
...command,
expectedEpoch: normalizeLeaseEpoch(input.expectedEpoch),
leaseId: randomUUID(),
now: now.toISOString(),
expiresAt: expiresAt(now, command.ttlMs),
});
}
async heartbeat(input: HeartbeatConnectorLeaseInput): Promise<ConnectorLease> {
const now = this.now();
const lease = normalizeConnectorLease(input.lease);
const ttlMs = normalizeTtl(input.ttlMs, this.maxLeaseTtlMs, 'lease');
return this.store.heartbeat({
lease,
ttlMs,
correlationId: normalizeCorrelationId(input.correlationId),
now: now.toISOString(),
expiresAt: expiresAt(now, ttlMs),
});
}
async release(input: ReleaseConnectorLeaseInput): Promise<void> {
await this.store.release({
lease: normalizeConnectorLease(input.lease),
correlationId: normalizeCorrelationId(input.correlationId),
now: this.now().toISOString(),
});
}
async current(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
return this.store.findCurrent(normalizeBinding(binding));
}
async issueGrant(input: IssueConnectorExecutionGrantInput): Promise<ConnectorExecutionGrant> {
const now = this.now();
const lease = normalizeConnectorLease(input.lease);
const scopes = normalizeConnectorScopes(input.scopes);
const correlationId = normalizeCorrelationId(input.correlationId);
const ttlMs = normalizeTtl(input.ttlMs, this.maxGrantTtlMs, 'grant');
const current = await this.store.findCurrent(lease);
await this.assertCurrentLease(current, lease, now, correlationId);
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
if (!isScopeSubset(scopes, lease.scopes) || !isScopeSubset(scopes, current.scopes)) {
await this.reject(lease, correlationId, now, 'scope_denied');
}
const requestedExpiry = new Date(now.getTime() + ttlMs);
const leaseExpiry = new Date(current.expiresAt);
const grantExpiry = requestedExpiry < leaseExpiry ? requestedExpiry : leaseExpiry;
const grant: ConnectorExecutionGrant = Object.freeze({
identity: current.identity,
bindingId: current.bindingId,
leaseId: current.leaseId,
connectorId: current.connectorId,
scopes,
leaseEpoch: current.leaseEpoch,
issuedAt: now.toISOString(),
expiresAt: grantExpiry.toISOString(),
correlationId,
});
this.issuedGrants.add(grant);
return grant;
}
async executeGrant<TInput, TOutput>(
grant: ConnectorExecutionGrant,
requiredScope: string,
input: TInput,
adapter: FencedConnectorAdapter<TInput, TOutput>,
): Promise<TOutput> {
const now = this.now();
const normalizedScope = normalizeConnectorScope(requiredScope);
if (!this.issuedGrants.has(grant)) {
await this.rejectForgedGrant(grant, now);
}
if (new Date(grant.expiresAt) <= now) {
await this.rejectGrant(grant, now, 'grant_expired');
}
const current = await this.store.findCurrent(normalizeBinding(grant));
await this.assertCurrentLease(current, grant, now, grant.correlationId);
if (!grant.scopes.includes(normalizedScope) || !current?.scopes.includes(normalizedScope)) {
await this.rejectGrant(grant, now, 'scope_denied');
}
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
const context: ConnectorExecutionContext = Object.freeze({
identity: current.identity,
bindingId: current.bindingId,
leaseId: current.leaseId,
connectorId: current.connectorId,
scopes: Object.freeze([...grant.scopes]),
leaseEpoch: current.leaseEpoch,
correlationId: grant.correlationId,
grantExpiresAt: grant.expiresAt,
});
return adapter.execute(input, context);
}
private async assertCurrentLease(
current: ConnectorLease | null,
authority: ConnectorLease | ConnectorExecutionGrant,
now: Date,
correlationId: string,
): Promise<void> {
if (!current) await this.reject(authority, correlationId, now, 'lease_missing');
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
if (current.releasedAt) await this.reject(authority, correlationId, now, 'lease_released');
if (new Date(current.expiresAt) <= now) {
await this.store.recordAudit(auditEvent(current, correlationId, now, 'expiry', 'succeeded'));
await this.reject(authority, correlationId, now, 'lease_expired');
}
if (current.leaseEpoch !== authority.leaseEpoch) {
await this.reject(authority, correlationId, now, 'stale_epoch');
}
if (current.leaseId !== authority.leaseId || current.connectorId !== authority.connectorId) {
await this.reject(authority, correlationId, now, 'connector_mismatch');
}
}
private async rejectGrant(
grant: ConnectorExecutionGrant,
now: Date,
reason: ConnectorLeaseRejectReason,
): Promise<never> {
return this.reject(grant, grant.correlationId, now, reason);
}
private async rejectForgedGrant(grant: unknown, now: Date): Promise<never> {
const event = safeForgedGrantAudit(grant, now);
await this.store.recordAudit(event);
throw new ConnectorLeaseError('forged_grant', safeReasonMessage('forged_grant'));
}
private async reject(
authority: LogicalAgentBinding & {
readonly connectorId: string;
readonly leaseId?: string;
readonly leaseEpoch?: string;
},
correlationId: string,
now: Date,
reason: ConnectorLeaseRejectReason,
): Promise<never> {
await this.store.recordAudit(
auditEvent(authority, correlationId, now, 'reject', 'denied', reason),
);
throw new ConnectorLeaseError(reason, safeReasonMessage(reason));
}
}
function normalizeAcquireInput(
input: AcquireConnectorLeaseInput,
maxLeaseTtlMs: number,
): AcquireConnectorLeaseInput {
return {
identity: normalizeLogicalAgentIdentity(input.identity),
bindingId: normalizeLogicalBindingId(input.bindingId),
connectorId: normalizeConnectorId(input.connectorId),
scopes: normalizeConnectorScopes(input.scopes),
ttlMs: normalizeTtl(input.ttlMs, maxLeaseTtlMs, 'lease'),
correlationId: normalizeCorrelationId(input.correlationId),
};
}
function normalizeBinding(input: LogicalAgentBinding): LogicalAgentBinding {
return {
identity: normalizeLogicalAgentIdentity(input.identity),
bindingId: normalizeLogicalBindingId(input.bindingId),
};
}
export function normalizeConnectorLease(lease: ConnectorLease): ConnectorLease {
const binding = normalizeBinding(lease);
return Object.freeze({
...binding,
leaseId: lease.leaseId,
connectorId: normalizeConnectorId(lease.connectorId),
scopes: normalizeConnectorScopes(lease.scopes),
leaseEpoch: normalizeLeaseEpoch(lease.leaseEpoch),
acquiredAt: normalizeTimestamp(lease.acquiredAt, 'lease acquisition'),
heartbeatAt: normalizeTimestamp(lease.heartbeatAt, 'lease heartbeat'),
expiresAt: normalizeTimestamp(lease.expiresAt, 'lease expiry'),
...(lease.releasedAt
? { releasedAt: normalizeTimestamp(lease.releasedAt, 'lease release') }
: {}),
});
}
function normalizeTtl(ttlMs: number, maximum: number, kind: 'lease' | 'grant'): number {
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > maximum) {
throw new Error(
`Connector ${kind} TTL must be a positive safe integer no greater than ${maximum}ms`,
);
}
return ttlMs;
}
function normalizeTtlLimit(ttlMs: number, hardMaximum: number, kind: 'lease' | 'grant'): number {
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > hardMaximum) {
throw new Error(
`Maximum connector ${kind} TTL must be a positive safe integer no greater than ${hardMaximum}ms`,
);
}
return ttlMs;
}
function normalizeTimestamp(value: string, label: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) throw new Error(`${label} timestamp is invalid`);
return date.toISOString();
}
function expiresAt(now: Date, ttlMs: number): string {
const expiry = new Date(now.getTime() + ttlMs);
if (Number.isNaN(expiry.getTime())) throw new Error('Connector lease TTL exceeds date range');
return expiry.toISOString();
}
function isScopeSubset(requested: readonly string[], allowed: readonly string[]): boolean {
return requested.every((scope) => allowed.includes(scope));
}
function auditEvent(
authority: LogicalAgentBinding & {
readonly connectorId: string;
readonly leaseId?: string;
readonly leaseEpoch?: string;
},
correlationId: string,
now: Date,
event: ConnectorLeaseAuditEvent['event'],
outcome: ConnectorLeaseAuditEvent['outcome'],
reason?: ConnectorLeaseRejectReason,
): ConnectorLeaseAuditEvent {
return {
identity: authority.identity,
bindingId: authority.bindingId,
connectorId: authority.connectorId,
correlationId,
occurredAt: now.toISOString(),
event,
outcome,
...(authority.leaseId ? { leaseId: authority.leaseId } : {}),
...(authority.leaseEpoch ? { leaseEpoch: authority.leaseEpoch } : {}),
...(reason ? { reason } : {}),
};
}
function safeForgedGrantAudit(grant: unknown, now: Date): ConnectorLeaseAuditEvent {
const fallback: ConnectorLeaseAuditEvent = {
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
bindingId: 'untrusted',
connectorId: 'untrusted',
correlationId: 'untrusted',
occurredAt: now.toISOString(),
event: 'reject',
outcome: 'denied',
reason: 'forged_grant',
};
if (typeof grant !== 'object' || grant === null || !('identity' in grant)) return fallback;
const identity = grant.identity;
if (typeof identity !== 'object' || identity === null) return fallback;
if (!('tenantId' in identity) || !('logicalAgentId' in identity)) return fallback;
if (!('bindingId' in grant) || !('connectorId' in grant) || !('correlationId' in grant)) {
return fallback;
}
if (
typeof identity.tenantId !== 'string' ||
typeof identity.logicalAgentId !== 'string' ||
typeof grant.bindingId !== 'string' ||
typeof grant.connectorId !== 'string' ||
typeof grant.correlationId !== 'string'
) {
return fallback;
}
try {
return {
identity: normalizeLogicalAgentIdentity({
tenantId: identity.tenantId,
logicalAgentId: identity.logicalAgentId,
}),
bindingId: normalizeLogicalBindingId(grant.bindingId),
connectorId: normalizeConnectorId(grant.connectorId),
correlationId: normalizeCorrelationId(grant.correlationId),
occurredAt: now.toISOString(),
event: 'reject',
outcome: 'denied',
reason: 'forged_grant',
};
} catch {
return fallback;
}
}
function safeReasonMessage(reason: ConnectorLeaseRejectReason): string {
return `Connector authority denied: ${reason}`;
}

View File

@@ -5,3 +5,4 @@ export * from './tmux-fleet-runtime-provider.js';
export * from './hermes-runtime-provider.js'; export * from './hermes-runtime-provider.js';
export * from './matrix-native-runtime-provider.js'; export * from './matrix-native-runtime-provider.js';
export * from './durable-session.js'; export * from './durable-session.js';
export * from './connector-lease.js';

View File

@@ -0,0 +1,36 @@
CREATE TABLE "connector_lease_audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" text NOT NULL,
"logical_agent_id" text NOT NULL,
"binding_id" text NOT NULL,
"connector_id" text NOT NULL,
"lease_id" uuid,
"lease_epoch" bigint,
"event" text NOT NULL,
"outcome" text NOT NULL,
"reason" text,
"correlation_id" text NOT NULL,
"occurred_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "logical_agent_connector_leases" (
"lease_id" uuid PRIMARY KEY NOT NULL,
"tenant_id" text NOT NULL,
"logical_agent_id" text NOT NULL,
"binding_id" text NOT NULL,
"connector_id" text NOT NULL,
"scopes" jsonb NOT NULL,
"lease_epoch" bigint NOT NULL,
"acquired_at" timestamp with time zone NOT NULL,
"heartbeat_at" timestamp with time zone NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"released_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "connector_lease_audit_binding_occurred_idx" ON "connector_lease_audit_log" USING btree ("tenant_id","logical_agent_id","binding_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "connector_lease_audit_correlation_idx" ON "connector_lease_audit_log" USING btree ("correlation_id");--> statement-breakpoint
CREATE UNIQUE INDEX "logical_agent_connector_lease_binding_idx" ON "logical_agent_connector_leases" USING btree ("tenant_id","logical_agent_id","binding_id");--> statement-breakpoint
CREATE INDEX "logical_agent_connector_lease_expiry_idx" ON "logical_agent_connector_leases" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "logical_agent_connector_lease_connector_idx" ON "logical_agent_connector_leases" USING btree ("connector_id");

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,13 @@
"when": 1783942610000, "when": 1783942610000,
"tag": "0015_interaction_checkpoint_payload_digest", "tag": "0015_interaction_checkpoint_payload_digest",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1784050648841,
"tag": "0016_salty_morlocks",
"breakpoints": true
} }
] ]
} }

View File

@@ -15,6 +15,7 @@ import {
uniqueIndex, uniqueIndex,
real, real,
integer, integer,
bigint,
customType, customType,
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
@@ -487,6 +488,68 @@ export const agentLogs = pgTable(
], ],
); );
// ─── Logical agent connector authority ──────────────────────────────────────
// One durable row is the current authority for a tenant/logical-agent/binding.
// Runtime-native session identifiers never enter these core tables.
export const logicalAgentConnectorLeases = pgTable(
'logical_agent_connector_leases',
{
leaseId: uuid('lease_id').primaryKey(),
tenantId: text('tenant_id').notNull(),
logicalAgentId: text('logical_agent_id').notNull(),
bindingId: text('binding_id').notNull(),
connectorId: text('connector_id').notNull(),
scopes: jsonb('scopes').notNull().$type<string[]>(),
leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }).notNull(),
acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull(),
heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }).notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
releasedAt: timestamp('released_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('logical_agent_connector_lease_binding_idx').on(
t.tenantId,
t.logicalAgentId,
t.bindingId,
),
index('logical_agent_connector_lease_expiry_idx').on(t.expiresAt),
index('logical_agent_connector_lease_connector_idx').on(t.connectorId),
],
);
/** Append-only, credential-safe lease lifecycle and fencing denial metadata. */
export const connectorLeaseAuditLog = pgTable(
'connector_lease_audit_log',
{
id: uuid('id').primaryKey().defaultRandom(),
tenantId: text('tenant_id').notNull(),
logicalAgentId: text('logical_agent_id').notNull(),
bindingId: text('binding_id').notNull(),
connectorId: text('connector_id').notNull(),
leaseId: uuid('lease_id'),
leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }),
event: text('event', {
enum: ['acquire', 'renew', 'takeover', 'reject', 'release', 'expiry'],
}).notNull(),
outcome: text('outcome', { enum: ['succeeded', 'denied'] }).notNull(),
reason: text('reason'),
correlationId: text('correlation_id').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
},
(t) => [
index('connector_lease_audit_binding_occurred_idx').on(
t.tenantId,
t.logicalAgentId,
t.bindingId,
t.occurredAt.desc(),
),
index('connector_lease_audit_correlation_idx').on(t.correlationId),
],
);
// ─── Tess durable session state ───────────────────────────────────────────── // ─── Tess durable session state ─────────────────────────────────────────────
// PostgreSQL is canonical for restart-safe Tess session recovery. The state // PostgreSQL is canonical for restart-safe Tess session recovery. The state
// machine lives in @mosaicstack/agent; these records are its durable adapter. // machine lives in @mosaicstack/agent; these records are its durable adapter.

View File

@@ -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 implicit `engineering` domain); cross-domain personas carry a `domain:` key in
their intro so tooling can group them. their intro so tooling can group them.
> This file is an index only — no code imports it. To add a persona, drop a new > This file is an index, not an authority source. The fleet persona resolver reads
> `*.md` next to the others (mirroring the existing structure) and add a row here. > 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 ## engineering
| Persona | Purpose | | Persona | Purpose |
| --------------- | ------------------------------------------------------------------------------ | | --------------- | ------------------------------------------------------------------------------ |
| orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work | | 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 | | 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 | | 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 | | 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 | | code | Primary executor — one card, one branch, one PR to green CI |
| review | Correctness reviewer — judges an open PR on correctness, scope, and coverage | | 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 | | 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 | | site-tester | Runtime verifier — runs the change and checks behavior vs. acceptance criteria |
| documentation | Prose maintainer — keeps human-facing docs and projections in sync | | 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 | | operator | Escalation and control surface — owns exceptions and the fleet pause switch |
| session-review | Post-task retrospective — turns finished work into improvement signals | | session-review | Post-task retrospective — turns finished work into improvement signals |
| enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness | | enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness |
| interaction | Operator request/status surface; routes orchestration and merge decisions |
## executive ## executive

View 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.

View 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.

View 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.

View File

@@ -1,13 +1,18 @@
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { import {
authorityForCanonicalClass,
canonicalizeRoleClass,
extractClassesFromDir, extractClassesFromDir,
extractClassesFromDirSync,
listPersonaClasses, listPersonaClasses,
personaStatus, personaStatus,
resolvePersona, resolvePersona,
resolvePersonaFrom,
resolvePersonaSync,
} from './fleet-personas.js'; } from './fleet-personas.js';
import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js'; import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js';
@@ -54,13 +59,156 @@ afterEach(async () => {
await rm(tmp, { recursive: true, force: true }); 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', 'constructor'])(
'does not infer an alias for %s',
(requested: string) => {
expect(canonicalizeRoleClass(requested)).toEqual({
requestedClass: requested,
canonicalClass: requested,
});
},
);
it('resolves inherited-property class names without granting protected authority', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'constructor.md'),
overridePersona('constructor', 'custom'),
'utf8',
);
const resolved = await resolvePersona('constructor', { rolesDir, overrideDir });
expect(resolved).toMatchObject({
requestedClass: 'constructor',
canonicalClass: 'constructor',
klass: 'constructor',
layer: 'override',
});
expect(authorityForCanonicalClass('constructor')).toMatchObject({
mayMerge: false,
mayIssueValidationCertificate: false,
mayOrchestrate: false,
});
});
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)', () => { describe('extractClassesFromDir (shared extraction)', () => {
it('records class + domain from inline markers and degrades on missing dir', async () => { it('records class + domain and distinguishes scanned from missing directories', async () => {
const base = await extractClassesFromDir(rolesDir); const base = await extractClassesFromDir(rolesDir);
expect(base.scanState).toBe('scanned');
expect(base.classes.has('ceo')).toBe(true); expect(base.classes.has('ceo')).toBe(true);
expect(base.byClass.get('ceo')?.domain).toBe('executive'); expect(base.byClass.get('ceo')?.domain).toBe('executive');
const missing = await extractClassesFromDir(join(tmp, 'nope'));
const missingDir = join(tmp, 'nope');
const missing = await extractClassesFromDir(missingDir);
expect(missing.scanState).toBe('missing');
expect(missing.classes.size).toBe(0); expect(missing.classes.size).toBe(0);
expect(extractClassesFromDirSync(missingDir).scanState).toBe('missing');
}); });
}); });
@@ -81,6 +229,287 @@ describe('resolvePersona — override wins', () => {
expect(resolved?.content).toContain('BASELINE'); expect(resolved?.content).toContain('BASELINE');
}); });
it('does not let a LIBRARY-only row shadow a readable baseline persona', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'LIBRARY.md'),
'| Persona | Purpose |\n| --- | --- |\n| code | Index only |\n',
'utf8',
);
const resolved = await resolvePersona('code', { rolesDir, overrideDir });
expect(resolved?.layer).toBe('baseline');
expect(resolved?.content).toContain('BASELINE');
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
expect(
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
?.status,
).toBe('baseline');
});
it('does not let an incidental later class marker shadow a readable baseline persona', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'mascot.md'),
'# mascot\n\n(`class: mascot`)\n\nSee also (`class: code`).\n',
'utf8',
);
const resolved = await resolvePersona('code', { rolesDir, overrideDir });
expect(resolved?.layer).toBe('baseline');
expect(resolved?.content).toContain('BASELINE');
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('mascot')).toBe(true);
expect(
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
?.status,
).toBe('baseline');
});
it('does not advertise an incidental-only class through listing or status APIs', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'mascot.md'),
'# mascot\n\n(`class: mascot`)\n\nSee also (`class: phantom`).\n',
'utf8',
);
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('phantom')).toBe(false);
expect(
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'phantom'),
).toBe(false);
});
it('rejects a canonical filename whose explicit marker names another class', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(join(overrideDir, 'merge-gate.md'), overridePersona('mascot', 'fun'), 'utf8');
await writeFile(
join(rolesDir, 'merge-gate.md'),
baselinePersona('merge-gate', 'governance'),
'utf8',
);
expect(await resolvePersona('merge-gate', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('merge-gate', { rolesDir, overrideDir })).toBeNull();
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('merge-gate')).toBe(false);
expect(
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'merge-gate'),
).toBe(false);
});
it('fails closed if a marker-defined override becomes unreadable after extraction', async () => {
await mkdir(overrideDir, { recursive: true });
const overrideFile = join(overrideDir, 'engineering.md');
await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8');
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
await rm(overrideFile);
await mkdir(overrideFile);
expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull();
});
it('fails closed if a marker-defined override changes identity after extraction', async () => {
await mkdir(overrideDir, { recursive: true });
const overrideFile = join(overrideDir, 'engineering.md');
await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8');
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8');
expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull();
const classes = await listPersonaClasses({ rolesDir, overrideDir });
expect(classes.has('code')).toBe(true);
expect(classes.has('mascot')).toBe(true);
const status = new Map(
(await personaStatus({ rolesDir, overrideDir })).map((entry) => [entry.klass, entry]),
);
expect(status.get('code')?.status).toBe('baseline');
expect(status.get('mascot')?.status).toBe('custom');
});
it('fails closed if a markerless cached override gains a conflicting marker', async () => {
await mkdir(overrideDir, { recursive: true });
const overrideFile = join(overrideDir, 'merge-gate.md');
await writeFile(overrideFile, '# markerless merge gate\n', 'utf8');
await writeFile(
join(rolesDir, 'merge-gate.md'),
baselinePersona('merge-gate', 'governance'),
'utf8',
);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8');
expect(
await resolvePersonaFrom('merge-gate', { rolesDir, overrideDir, base, over }),
).toBeNull();
});
it('fails closed asynchronously when the override directory cannot be scanned', async () => {
await writeFile(overrideDir, 'not a directory', 'utf8');
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
});
it('fails closed synchronously when the override directory cannot be scanned', async () => {
await writeFile(overrideDir, 'not a directory', 'utf8');
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
});
it('fails closed asynchronously and synchronously for a dangling override-directory symlink', async () => {
await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir');
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
});
it('fails closed asynchronously and synchronously when an override ancestor is a dangling symlink', async () => {
const danglingAncestor = join(tmp, 'dangling-ancestor');
await symlink(join(tmp, 'missing-ancestor-target'), danglingAncestor, 'dir');
overrideDir = join(danglingAncestor, 'nested', 'roles.local');
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
});
it('fails closed when dot-dot traversal crosses a dangling override ancestor', async () => {
const danglingAncestor = join(tmp, 'dangling-dotdot-ancestor');
await symlink(join(tmp, 'missing-dotdot-target'), danglingAncestor, 'dir');
overrideDir = `${danglingAncestor}/../roles.local`;
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
});
it('fails closed when relative dot-dot traversal crosses a dangling override ancestor', async () => {
const danglingAncestor = join(tmp, 'relative-dangling-ancestor');
await symlink(join(tmp, 'missing-relative-target'), danglingAncestor, 'dir');
overrideDir = `${relative(process.cwd(), danglingAncestor)}/../roles.local`;
expect(overrideDir.startsWith('/')).toBe(false);
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
});
it('revalidates a cached missing override before baseline fallback', async () => {
const cachedOverrideDir = join(tmp, 'mutable-ancestor', 'roles.local');
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(cachedOverrideDir),
]);
expect(over.scanState).toBe('missing');
await symlink(join(tmp, 'missing-mutable-target'), join(tmp, 'mutable-ancestor'), 'dir');
expect(
await resolvePersonaFrom('code', {
rolesDir,
overrideDir: cachedOverrideDir,
base,
over,
}),
).toBeNull();
});
it('revalidates a cached scanned override before baseline fallback', async () => {
await mkdir(overrideDir, { recursive: true });
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
expect(over.scanState).toBe('scanned');
expect(over.classes.size).toBe(0);
await writeFile(join(overrideDir, 'engineering.md'), overridePersona('code', 'engineering'));
const resolved = await resolvePersonaFrom('code', {
rolesDir,
overrideDir,
base,
over,
});
expect(resolved?.layer).toBe('override');
expect(resolved?.file).toBe(join(overrideDir, 'engineering.md'));
});
it('falls back to baseline asynchronously and synchronously when override directory is missing', async () => {
const asyncResolution = await resolvePersona('code', { rolesDir, overrideDir });
const syncResolution = resolvePersonaSync('code', { rolesDir, overrideDir });
expect(asyncResolution?.layer).toBe('baseline');
expect(syncResolution?.layer).toBe('baseline');
});
it('fails closed asynchronously when the canonical override exists but is unreadable', async () => {
await mkdir(overrideDir, { recursive: true });
await mkdir(join(overrideDir, 'code.md'));
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
});
it('does not advertise a shadowed baseline whose canonical override is unreadable', async () => {
await mkdir(overrideDir, { recursive: true });
await mkdir(join(overrideDir, 'code.md'));
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(false);
expect(
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'code'),
).toBe(false);
});
it('does not advertise baseline personas when the override directory is unscannable', async () => {
await writeFile(overrideDir, 'not a directory', 'utf8');
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
});
it('does not advertise baseline personas through a dangling override-directory symlink', async () => {
await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir');
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
});
it('preserves baseline listings when the override directory is missing', async () => {
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
expect(
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
?.status,
).toBe('baseline');
});
it('does not advertise an unreadable custom override as a valid persona class or status', async () => {
await mkdir(overrideDir, { recursive: true });
await mkdir(join(overrideDir, 'ghost.md'));
const extracted = await extractClassesFromDir(overrideDir);
expect(extracted.fileStems.has('ghost')).toBe(true);
expect(extracted.classes.has('ghost')).toBe(false);
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('ghost')).toBe(false);
expect(
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'ghost'),
).toBe(false);
});
it('fails closed synchronously when the canonical override exists but is unreadable', async () => {
await mkdir(overrideDir, { recursive: true });
await mkdir(join(overrideDir, 'code.md'));
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
});
it('returns null for an unknown class', async () => { it('returns null for an unknown class', async () => {
expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull(); expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull();
}); });

View File

@@ -25,10 +25,10 @@
* can reference a customized or user-added persona. * can reference a customized or user-added persona.
*/ */
import { readFileSync, readdirSync } from 'node:fs'; import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises'; import { lstat, readFile, readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { basename, join } from 'node:path'; import { basename, isAbsolute, join, sep } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
function defaultMosaicHome(): string { function defaultMosaicHome(): string {
@@ -53,52 +53,136 @@ export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g; const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g;
/** Optional `domain: Y` marker that travels alongside the class in the prose. */ /** Optional `domain: Y` marker that travels alongside the class in the prose. */
const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/; const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/;
/** LIBRARY.md persona rows: the first table cell is the persona name. */
const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm;
/** Where a resolved persona's definition came from. */ /** Where a resolved persona's definition came from. */
export type PersonaLayer = 'baseline' | 'override'; 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 = Object.hasOwn(ROLE_CLASS_ALIASES, requested)
? 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 Object.hasOwn(ROLE_AUTHORITY_BY_CANONICAL_CLASS, canonicalClass)
? ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass]!
: NO_PROTECTED_AUTHORITY;
}
/** One discovered persona file (a single role contract on disk). */ /** One discovered persona file (a single role contract on disk). */
export interface PersonaFile { export interface PersonaFile {
klass: string; klass: string;
/** The markdown file the class was found in. */ /** The markdown file the class was found in. */
file: string; file: string;
/** True when the first class marker, rather than filename, defined this mapping. */
markerDefined?: boolean;
domain?: string; domain?: string;
} }
export type DirScanState = 'scanned' | 'missing' | 'error';
/** The set of persona classes a directory of role contracts defines. */ /** The set of persona classes a directory of role contracts defines. */
export interface DirClasses { export interface DirClasses {
/** Every class name the dir contributes (markers + filenames + LIBRARY rows). */ /** Whether the directory was scanned, absent, or present-but-unscannable. */
scanState: DirScanState;
/** Every readable class name the directory contributes by filename or first marker. */
classes: Set<string>; classes: Set<string>;
/** Filename stems present on disk, retained even when a markdown entry is unreadable. */
fileStems: Set<string>;
/** For classes whose file carries a marker, the file + domain that defined it. */ /** For classes whose file carries a marker, the file + domain that defined it. */
byClass: Map<string, PersonaFile>; byClass: Map<string, PersonaFile>;
} }
/** /**
* Scan one directory of role contracts and extract the persona classes it * Scan one directory of role contracts and extract readable persona identities.
* defines. THIS is the shared extraction both fleet-personas and fleet-profiles * Valid identities come only from a successfully read non-LIBRARY filename and
* rely on. Sources, unioned (each needed — see module doc): * that file's first `class:` marker. LIBRARY rows and later prose mentions are
* 1. inline `class: X` markers in roles/*.md (primary; may wrap a newline), * index/reference data, not independently readable role contracts.
* 2. persona-name cells from LIBRARY.md index tables,
* 3. the role filename stem (covers marker-less alias docs like planner).
* *
* Missing dir / unreadable files degrade gracefully to whatever was found. * Missing directories are distinguished from present-but-unscannable paths.
* `byClass` records the defining file+domain for marker-bearing classes so the * `byClass` records the first marker-defined file+domain so the resolver can map
* resolver can map a class back to its file; filename-only and LIBRARY-only * a class back to its contract; marker-less readable files map by filename.
* classes still appear in `classes` for membership checks.
*/ */
export async function extractClassesFromDir(dir: string): Promise<DirClasses> { export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() }; const acc: DirClasses = {
scanState: 'scanned',
classes: new Set<string>(),
fileStems: new Set<string>(),
byClass: new Map<string, PersonaFile>(),
};
let entries: string[]; let entries: string[];
try { try {
entries = await readdir(dir); entries = await readdir(dir);
} catch { } catch (error) {
acc.scanState = await classifyScanFailure(dir, error);
return acc; return acc;
} }
for (const entry of entries) { for (const entry of entries) {
if (!entry.endsWith('.md')) continue; if (!entry.endsWith('.md')) continue;
// Preserve entry presence separately from valid readable classes. Resolvers
// use it to fail closed on an explicit unreadable canonical override without
// advertising that override through class listing/status APIs.
if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md'));
let text: string; let text: string;
try { try {
text = await readFile(join(dir, entry), 'utf8'); text = await readFile(join(dir, entry), 'utf8');
@@ -117,16 +201,25 @@ export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
* cannot await. Missing dir / unreadable files degrade gracefully. * cannot await. Missing dir / unreadable files degrade gracefully.
*/ */
export function extractClassesFromDirSync(dir: string): DirClasses { export function extractClassesFromDirSync(dir: string): DirClasses {
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() }; const acc: DirClasses = {
scanState: 'scanned',
classes: new Set<string>(),
fileStems: new Set<string>(),
byClass: new Map<string, PersonaFile>(),
};
let entries: string[]; let entries: string[];
try { try {
entries = readdirSync(dir); entries = readdirSync(dir);
} catch { } catch (error) {
acc.scanState = classifyScanFailureSync(dir, error);
return acc; return acc;
} }
for (const entry of entries) { for (const entry of entries) {
if (!entry.endsWith('.md')) continue; if (!entry.endsWith('.md')) continue;
// Keep sync extraction equivalent to the async scanner, including unreadable
// filename presence kept separate from valid readable classes.
if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md'));
let text: string; let text: string;
try { try {
text = readFileSync(join(dir, entry), 'utf8'); text = readFileSync(join(dir, entry), 'utf8');
@@ -138,6 +231,74 @@ export function extractClassesFromDirSync(dir: string): DirClasses {
return acc; return acc;
} }
async function classifyScanFailure(dir: string, error: unknown): Promise<DirScanState> {
if (!isMissingPathError(error)) return 'error';
return (await hasBrokenSymlinkInPath(dir)) ? 'error' : 'missing';
}
function classifyScanFailureSync(dir: string, error: unknown): DirScanState {
if (!isMissingPathError(error)) return 'error';
return hasBrokenSymlinkInPathSync(dir) ? 'error' : 'missing';
}
function traversalPrefixes(path: string): string[] {
const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`;
const parts = absolute.split(sep);
const prefixes: string[] = [];
let current: string = sep;
for (const part of parts) {
if (!part) continue;
current = current === sep ? `${sep}${part}` : `${current}${sep}${part}`;
prefixes.push(current);
}
return prefixes;
}
async function hasBrokenSymlinkInPath(path: string): Promise<boolean> {
for (const current of traversalPrefixes(path)) {
try {
const entry = await lstat(current);
if (entry.isSymbolicLink()) {
try {
await stat(current);
} catch {
return true;
}
}
} catch (error) {
if (!isMissingPathError(error)) return true;
}
}
return false;
}
function hasBrokenSymlinkInPathSync(path: string): boolean {
for (const current of traversalPrefixes(path)) {
try {
const entry = lstatSync(current);
if (entry.isSymbolicLink()) {
try {
statSync(current);
} catch {
return true;
}
}
} catch (error) {
if (!isMissingPathError(error)) return true;
}
}
return false;
}
function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'ENOENT'
);
}
/** /**
* Apply the class-extraction rules for ONE role file's text into `acc`. Pure * Apply the class-extraction rules for ONE role file's text into `acc`. Pure
* over already-read content, so the async and sync directory scanners share a * over already-read content, so the async and sync directory scanners share a
@@ -146,33 +307,26 @@ export function extractClassesFromDirSync(dir: string): DirClasses {
*/ */
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void { function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void {
const { classes, byClass } = acc; const { classes, byClass } = acc;
if (entry === 'LIBRARY.md') { if (entry === 'LIBRARY.md') return;
for (const m of text.matchAll(LIBRARY_ROW)) {
const name = m[1]; // An explicit first marker owns identity. Filename identity is a fallback only
if (name && name !== 'persona') classes.add(name); // for markerless compatibility contracts such as planner.md.
}
return;
}
// The filename stem is itself a valid class (covers marker-less alias docs).
const stem = basename(entry, '.md'); const stem = basename(entry, '.md');
classes.add(stem);
const domainMatch = DOMAIN_MARKER.exec(text); const domainMatch = DOMAIN_MARKER.exec(text);
const domain = domainMatch?.[1]; const domain = domainMatch?.[1];
let markedClassForFile: string | undefined; const firstMarker = text.matchAll(CLASS_MARKER).next().value as RegExpExecArray | undefined;
for (const m of text.matchAll(CLASS_MARKER)) { const markedClassForFile = firstMarker?.[1];
const klass = m[1]; if (markedClassForFile) {
if (!klass) continue; classes.add(markedClassForFile);
classes.add(klass); byClass.set(markedClassForFile, {
// Record the FIRST marker as the file's defining class (the prose names klass: markedClassForFile,
// the persona's own class up top; later mentions reference siblings). file: join(dir, entry),
if (!markedClassForFile) { markerDefined: true,
markedClassForFile = klass; ...(domain ? { domain } : {}),
byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) }); });
} } else {
} classes.add(stem);
// A marker-less file still maps its stem to itself (no domain known). if (!byClass.has(stem)) byClass.set(stem, { klass: stem, file: join(dir, entry) });
if (!markedClassForFile && !byClass.has(stem)) {
byClass.set(stem, { klass: stem, file: join(dir, entry) });
} }
} }
@@ -193,24 +347,19 @@ function resolveDirs(opts: PersonaDirs): { rolesDir: string; overrideDir: string
} }
/** /**
* UNION of baseline classes and override classes. Overrides may ADD entirely new * Every class with an actually readable winning contract. Discovery applies the
* classes not present in the baseline, so callers (e.g. profile roster * same override shadow/fail-closed semantics as resolution, so callers never
* validation) treat a user-added persona as a real class. * advertise a class that cannot be used.
*/ */
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> { export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
const { rolesDir, overrideDir } = resolveDirs(opts); const { personas } = await collectUsablePersonas(opts);
const [base, over] = await Promise.all([ return new Set(personas.keys());
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const union = new Set<string>(base.classes);
for (const c of over.classes) union.add(c);
return union;
} }
export type PersonaStatus = 'baseline' | 'overridden' | 'custom'; export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
export interface PersonaResolution { export interface PersonaResolution extends CanonicalRoleClass {
/** Compatibility name for the canonical class. */
klass: string; klass: string;
layer: PersonaLayer; layer: PersonaLayer;
/** The file the resolved persona was read from (override wins). */ /** The file the resolved persona was read from (override wins). */
@@ -219,6 +368,118 @@ export interface PersonaResolution {
domain?: string; domain?: string;
} }
async function readPersonaFromLayer(
requestedClass: string,
canonicalClass: string,
dir: string,
extracted: DirClasses,
layer: PersonaLayer,
): Promise<PersonaResolution | null> {
const pf = extracted.byClass.get(canonicalClass);
if (!pf) {
if (!extracted.classes.has(canonicalClass)) return null;
const byName = join(dir, `${canonicalClass}.md`);
try {
const content = await readFile(byName, 'utf8');
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
| RegExpExecArray
| undefined;
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
const dm = DOMAIN_MARKER.exec(content);
return {
requestedClass,
canonicalClass,
klass: canonicalClass,
layer,
file: byName,
content,
...(dm?.[1] ? { domain: dm[1] } : {}),
};
} catch {
return null;
}
}
try {
const content = await readFile(pf.file, 'utf8');
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
| RegExpExecArray
| undefined;
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
const dm = DOMAIN_MARKER.exec(content);
return {
requestedClass,
canonicalClass,
klass: canonicalClass,
layer,
file: pf.file,
content,
...(dm?.[1] ? { domain: dm[1] } : {}),
};
} catch {
return null;
}
}
function overrideShadowsBaseline(over: DirClasses, canonicalClass: string): boolean {
return (
over.scanState === 'error' ||
over.fileStems.has(canonicalClass) ||
over.byClass.has(canonicalClass)
);
}
function readPersonaFromLayerSync(
requestedClass: string,
canonicalClass: string,
dir: string,
extracted: DirClasses,
layer: PersonaLayer,
): PersonaResolution | null {
const pf = extracted.byClass.get(canonicalClass);
if (!pf) {
if (!extracted.classes.has(canonicalClass)) return null;
const byName = join(dir, `${canonicalClass}.md`);
try {
const content = readFileSync(byName, 'utf8');
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
| RegExpExecArray
| undefined;
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
const dm = DOMAIN_MARKER.exec(content);
return {
requestedClass,
canonicalClass,
klass: canonicalClass,
layer,
file: byName,
content,
...(dm?.[1] ? { domain: dm[1] } : {}),
};
} catch {
return null;
}
}
try {
const content = readFileSync(pf.file, 'utf8');
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
| RegExpExecArray
| undefined;
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
const dm = DOMAIN_MARKER.exec(content);
return {
requestedClass,
canonicalClass,
klass: canonicalClass,
layer,
file: pf.file,
content,
...(dm?.[1] ? { domain: dm[1] } : {}),
};
} catch {
return null;
}
}
/** /**
* Resolve a persona class to its winning definition: the override file if * Resolve a persona class to its winning definition: the override file if
* roles.local/ defines that class, else the baseline. Match by inline `class:` * roles.local/ defines that class, else the baseline. Match by inline `class:`
@@ -245,42 +506,32 @@ export async function resolvePersona(
* is identical to {@link resolvePersona}: override layer wins, then baseline. * is identical to {@link resolvePersona}: override layer wins, then baseline.
*/ */
export async function resolvePersonaFrom( export async function resolvePersonaFrom(
klass: string, requestedClass: string,
layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses }, layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
): Promise<PersonaResolution | null> { ): Promise<PersonaResolution | null> {
const { requestedClass: requested, canonicalClass: klass } =
canonicalizeRoleClass(requestedClass);
const { rolesDir, overrideDir, base, over } = layers; const { rolesDir, overrideDir, base, over } = layers;
const fromLayer = async ( const override = await readPersonaFromLayer(requested, klass, overrideDir, over, 'override');
dir: string, if (override) return override;
extracted: DirClasses, // An observed explicit override that cannot resolve/read shadows the baseline.
layer: PersonaLayer, if (overrideShadowsBaseline(over, klass)) return null;
): Promise<PersonaResolution | null> => {
// Prefer the marker-defined file; fall back to the filename stem.
let pf = extracted.byClass.get(klass);
if (!pf) {
const byName = join(dir, `${klass}.md`);
if (!extracted.classes.has(klass)) return null;
// Class known only via filename/LIBRARY: read the stem file if present.
try {
const content = await readFile(byName, 'utf8');
const dm = DOMAIN_MARKER.exec(content);
return { 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 } : {}) };
} catch {
return null;
}
};
return ( // Cached scans are only snapshots. Re-scan immediately before baseline fallback
(await fromLayer(overrideDir, over, 'override')) ?? // so a newly created canonical or marker-defined override cannot be skipped.
(await fromLayer(rolesDir, base, 'baseline')) const currentOver = await extractClassesFromDir(overrideDir);
const currentOverride = await readPersonaFromLayer(
requested,
klass,
overrideDir,
currentOver,
'override',
); );
if (currentOverride) return currentOverride;
if (overrideShadowsBaseline(currentOver, klass)) return null;
if (currentOver.scanState === 'error') return null;
return readPersonaFromLayer(requested, klass, rolesDir, base, 'baseline');
} }
/** /**
@@ -292,40 +543,55 @@ export async function resolvePersonaFrom(
* one module so the launch-time and command-time resolutions never diverge. * one module so the launch-time and command-time resolutions never diverge.
*/ */
export function resolvePersonaSync( export function resolvePersonaSync(
klass: string, requestedClass: string,
opts: PersonaDirs = {}, opts: PersonaDirs = {},
): PersonaResolution | null { ): PersonaResolution | null {
const { requestedClass: requested, canonicalClass: klass } =
canonicalizeRoleClass(requestedClass);
const { rolesDir, overrideDir } = resolveDirs(opts); const { rolesDir, overrideDir } = resolveDirs(opts);
const base = extractClassesFromDirSync(rolesDir); const base = extractClassesFromDirSync(rolesDir);
const over = extractClassesFromDirSync(overrideDir); const over = extractClassesFromDirSync(overrideDir);
const fromLayer = ( const override = readPersonaFromLayerSync(requested, klass, overrideDir, over, 'override');
dir: string, if (override) return override;
extracted: DirClasses, if (overrideShadowsBaseline(over, klass)) return null;
layer: PersonaLayer, return readPersonaFromLayerSync(requested, klass, rolesDir, base, 'baseline');
): PersonaResolution | null => { }
// Prefer the marker-defined file; fall back to the filename stem.
const pf = extracted.byClass.get(klass);
if (!pf) {
if (!extracted.classes.has(klass)) return null;
const byName = join(dir, `${klass}.md`);
try {
const content = readFileSync(byName, 'utf8');
const dm = DOMAIN_MARKER.exec(content);
return { 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 } : {}) };
} catch {
return null;
}
};
return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline'); interface UsablePersonaIndex {
personas: Map<string, PersonaResolution>;
readableBaseline: Set<string>;
}
async function collectUsablePersonas(opts: PersonaDirs): Promise<UsablePersonaIndex> {
const { rolesDir, overrideDir } = resolveDirs(opts);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
if (over.scanState === 'error') {
return { personas: new Map(), readableBaseline: new Set() };
}
const candidates = new Set<string>([...base.classes, ...over.classes]);
const checked = await Promise.all(
[...candidates].map(async (requestedClass) => {
const { canonicalClass } = canonicalizeRoleClass(requestedClass);
const [persona, baseline] = await Promise.all([
resolvePersonaFrom(requestedClass, { rolesDir, overrideDir, base, over }),
readPersonaFromLayer(requestedClass, canonicalClass, rolesDir, base, 'baseline'),
]);
return { requestedClass, persona, baseline };
}),
);
const personas = new Map<string, PersonaResolution>();
const readableBaseline = new Set<string>();
for (const { requestedClass, persona, baseline } of checked) {
if (persona) personas.set(requestedClass, persona);
if (baseline) readableBaseline.add(requestedClass);
}
return { personas, readableBaseline };
} }
export interface PersonaStatusEntry { export interface PersonaStatusEntry {
@@ -342,24 +608,20 @@ export interface PersonaStatusEntry {
* Domain is taken from the WINNING layer (override domain wins if present). * Domain is taken from the WINNING layer (override domain wins if present).
*/ */
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> { export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
const { rolesDir, overrideDir } = resolveDirs(opts); const { personas, readableBaseline } = await collectUsablePersonas(opts);
const [base, over] = await Promise.all([ const entries = [...personas.entries()].map(([klass, persona]): PersonaStatusEntry => {
extractClassesFromDir(rolesDir), const status: PersonaStatus =
extractClassesFromDir(overrideDir), persona.layer === 'baseline'
]); ? 'baseline'
: readableBaseline.has(klass)
const all = new Set<string>([...base.classes, ...over.classes]); ? 'overridden'
const domainOf = (extracted: DirClasses, klass: string): string | undefined => : 'custom';
extracted.byClass.get(klass)?.domain; return {
klass,
const entries: PersonaStatusEntry[] = []; status,
for (const klass of all) { ...(persona.domain ? { domain: persona.domain } : {}),
const inBase = base.classes.has(klass); };
const inOver = over.classes.has(klass); });
const status: PersonaStatus = inOver ? (inBase ? 'overridden' : 'custom') : 'baseline';
const domain = (inOver ? domainOf(over, klass) : undefined) ?? domainOf(base, klass);
entries.push({ klass, status, ...(domain ? { domain } : {}) });
}
entries.sort((a, b) => a.klass.localeCompare(b.klass)); entries.sort((a, b) => a.klass.localeCompare(b.klass));
return entries; return entries;
} }

View File

@@ -203,6 +203,91 @@ describe('loadProfiles with a temp override dir', () => {
await rm(dir, { recursive: true, force: true }); 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 () => { it('throws when a profile references an unknown class (validated against real roles)', async () => {
await writeFile( await writeFile(
join(dir, 'bad.yaml'), join(dir, 'bad.yaml'),

View File

@@ -29,6 +29,7 @@ import {
defaultOverrideDir, defaultOverrideDir,
extractClassesFromDir, extractClassesFromDir,
listPersonaClasses as listOverrideAwarePersonaClasses, listPersonaClasses as listOverrideAwarePersonaClasses,
resolvePersonaFrom,
} from './fleet-personas.js'; } from './fleet-personas.js';
function defaultMosaicHome(): string { function defaultMosaicHome(): string {
@@ -199,6 +200,61 @@ export function validateProfile(profile: FleetProfile, validClasses: Set<string>
return problems; 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 { export interface LoadProfilesOptions {
/** Override the profiles dir (tests). Defaults to <mosaicHome>/fleet/profiles. */ /** Override the profiles dir (tests). Defaults to <mosaicHome>/fleet/profiles. */
profilesDir?: string; profilesDir?: string;
@@ -237,10 +293,8 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
} }
files.sort(); files.sort();
// Override-aware: a profile may reference a user-customized or user-ADDED // Validation resolves each reference to an actually readable winning persona;
// persona living in the roles.local/ layer (H4), so validate against the // LIBRARY membership alone is not semantic success.
// baseline ⊕ override union, not the baseline alone.
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
const profiles: FleetProfile[] = []; const profiles: FleetProfile[] = [];
const seen = new Map<string, string>(); const seen = new Map<string, string>();
@@ -255,11 +309,14 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
} }
seen.set(profile.id, file); seen.set(profile.id, file);
const problems = validateProfile(profile, validClasses); let resolvedProfile: FleetProfile;
if (problems.length > 0) { try {
throw new Error(`Profile ${file} is invalid:\n - ${problems.join('\n - ')}`); 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; return profiles;
} }

View File

@@ -172,6 +172,46 @@ describe('override-aware persona validation', () => {
expect(result.summary).toContain('persona=override'); 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 () => { it('FAILS with a clear message when a profile references a bogus class', async () => {
const customProfilesDir = join(dir, 'profiles'); const customProfilesDir = join(dir, 'profiles');
await mkdir(customProfilesDir, { recursive: true }); await mkdir(customProfilesDir, { recursive: true });

View File

@@ -26,12 +26,11 @@ import type { Command } from 'commander';
import YAML from 'yaml'; import YAML from 'yaml';
import { import {
loadProfile, loadProfile,
validateProfile,
type FleetProfile, type FleetProfile,
type ProfileRosterEntry, type ProfileRosterEntry,
defaultProfilesDir, defaultProfilesDir,
defaultRolesDir, defaultRolesDir,
listPersonaClassesWithOverrides, resolveProfilePersonas,
} from './fleet-profiles.js'; } from './fleet-profiles.js';
import { import {
defaultOverrideDir, defaultOverrideDir,
@@ -201,18 +200,35 @@ export async function generateRoster(
); );
} }
const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead); const canonicalEntry: ProfileRosterEntry = {
for (const name of seatNames(entry)) { 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 = { const seat: GeneratedSeat = {
name, name,
className: entry.class, className: resolved.canonicalClass,
runtime: runtimeChoice.runtime, runtime: runtimeChoice.runtime,
personaLayer: resolved.layer, personaLayer: resolved.layer,
}; };
if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint; if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint;
if (isFloor || isLead) seat.persistentPersona = true; if (isFloor || isLead) seat.persistentPersona = true;
if (!isFloor && !isLead) seat.resetBetweenTasks = true; if (!isFloor && !isLead) seat.resetBetweenTasks = true;
if (entry.reportsTo) seat.reportsTo = entry.reportsTo; if (canonicalEntry.reportsTo) seat.reportsTo = canonicalEntry.reportsTo;
seats.push(seat); seats.push(seat);
} }
} }
@@ -279,13 +295,7 @@ export async function validateProfileForProvision(
opts: ProvisionOptions, opts: ProvisionOptions,
): Promise<void> { ): Promise<void> {
const { rolesDir, overrideDir } = resolveDirs(opts); const { rolesDir, overrideDir } = resolveDirs(opts);
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir); await resolveProfilePersonas(profile, 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 - ')}`,
);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -0,0 +1,90 @@
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import {
SHIPPED_FLEET_ARTIFACT_DISPOSITIONS,
validateShippedFleetArtifactDispositions,
} from './example-profile-dispositions.js';
const frameworkFleet = resolve(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'framework',
'fleet',
);
const EXPECTED_DISPOSITIONS = [
'examples/coding.yaml:v1-fixture',
'examples/general.yaml:v1-fixture',
'examples/hybrid.yaml:v1-fixture',
'examples/local-canary.yaml:v1-fixture',
'examples/minimal.yaml:v1-fixture',
'examples/operator-interaction.yaml:v1-fixture',
'examples/research.yaml:v1-fixture',
'profiles/business.yaml:canonical-profile',
'profiles/marketing.yaml:canonical-profile',
'profiles/personal-assistant.yaml:canonical-profile',
'profiles/research.yaml:canonical-profile',
'profiles/software-delivery.yaml:canonical-profile',
'services/operator-interaction.yaml:canonical-service-policy',
];
const declared = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(
({ path, disposition }): string => `${path}:${disposition}`,
);
describe('shipped fleet example/profile/service disposition validation', (): void => {
it('enumerates every inventory artifact with an explicit executable disposition', (): void => {
expect(declared).toEqual(EXPECTED_DISPOSITIONS);
});
it('validates every shipped artifact through its declared v1 or canonical path', async (): Promise<void> => {
const results = await validateShippedFleetArtifactDispositions({ frameworkFleet });
expect(results.map(({ path, disposition }): string => `${path}:${disposition}`)).toEqual(
EXPECTED_DISPOSITIONS,
);
expect(results.filter(({ disposition }): boolean => disposition === 'v1-fixture')).toHaveLength(
7,
);
expect(
results.filter(({ disposition }): boolean => disposition === 'canonical-profile'),
).toHaveLength(5);
expect(
results.find(({ path }): boolean => path === 'services/operator-interaction.yaml'),
).toMatchObject({
disposition: 'canonical-service-policy',
});
});
let temporaryFleet: string | undefined;
afterEach(async (): Promise<void> => {
if (temporaryFleet) await rm(temporaryFleet, { recursive: true, force: true });
temporaryFleet = undefined;
});
it('fails closed when a shipped artifact lacks a declared disposition', async (): Promise<void> => {
temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-'));
await cp(frameworkFleet, temporaryFleet, { recursive: true });
await writeFile(join(temporaryFleet, 'examples', 'undeclared.yaml'), 'version: 1\n');
await expect(
validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }),
).rejects.toThrow(/undeclared shipped fleet artifact.*examples\/undeclared.yaml/i);
});
it('rejects a v1 fixture when its explicit version declaration is removed', async (): Promise<void> => {
temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-'));
await cp(frameworkFleet, temporaryFleet, { recursive: true });
const fixturePath = join(temporaryFleet, 'examples', 'coding.yaml');
const fixture = await readFile(fixturePath, 'utf8');
await writeFile(fixturePath, fixture.replace(/^version: 1\n/, ''));
await expect(
validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }),
).rejects.toThrow(/Fleet roster version must be 1/);
});
});

View File

@@ -0,0 +1,121 @@
import { readdir } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { loadFleetRoster } from '../commands/fleet.js';
import { loadProfiles } from '../commands/fleet-profiles.js';
import {
provisionInteractionService,
readInteractionServiceProfile,
} from './interaction-service-profile.js';
export type FleetArtifactDisposition =
| 'v1-fixture'
| 'canonical-profile'
| 'canonical-service-policy';
export interface ShippedFleetArtifactDisposition {
readonly path: string;
readonly disposition: FleetArtifactDisposition;
}
export interface ValidateShippedFleetArtifactDispositionsOptions {
readonly frameworkFleet: string;
readonly rolesDir?: string;
readonly overrideDir?: string;
}
/**
* The M0 inventory in executable form. Every shipped fleet YAML asset is either
* a deliberately retained v1 fixture or validated through its canonical loader.
*/
export const SHIPPED_FLEET_ARTIFACT_DISPOSITIONS: readonly ShippedFleetArtifactDisposition[] = [
{ path: 'examples/coding.yaml', disposition: 'v1-fixture' },
{ path: 'examples/general.yaml', disposition: 'v1-fixture' },
{ path: 'examples/hybrid.yaml', disposition: 'v1-fixture' },
{ path: 'examples/local-canary.yaml', disposition: 'v1-fixture' },
{ path: 'examples/minimal.yaml', disposition: 'v1-fixture' },
{ path: 'examples/operator-interaction.yaml', disposition: 'v1-fixture' },
{ path: 'examples/research.yaml', disposition: 'v1-fixture' },
{ path: 'profiles/business.yaml', disposition: 'canonical-profile' },
{ path: 'profiles/marketing.yaml', disposition: 'canonical-profile' },
{ path: 'profiles/personal-assistant.yaml', disposition: 'canonical-profile' },
{ path: 'profiles/research.yaml', disposition: 'canonical-profile' },
{ path: 'profiles/software-delivery.yaml', disposition: 'canonical-profile' },
{ path: 'services/operator-interaction.yaml', disposition: 'canonical-service-policy' },
];
/**
* Fail closed when a fleet YAML asset is added or removed without a disposition.
* This keeps legacy v1 compatibility explicit instead of silently accepting new
* unresolved classes outside the shared resolver.
*/
export async function validateShippedFleetArtifactDispositions(
options: ValidateShippedFleetArtifactDispositionsOptions,
): Promise<readonly ShippedFleetArtifactDisposition[]> {
await assertEveryShippedArtifactIsDeclared(options.frameworkFleet);
const examples = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
({ disposition }): boolean => disposition === 'v1-fixture',
);
for (const artifact of examples) {
const roster = await loadFleetRoster(join(options.frameworkFleet, artifact.path));
if (roster.version !== 1) {
throw new Error(`v1 fixture ${artifact.path} must declare version: 1`);
}
}
const profilesDir = join(options.frameworkFleet, 'profiles');
const profiles = await loadProfiles({
profilesDir,
rolesDir: options.rolesDir ?? join(options.frameworkFleet, 'roles'),
overrideDir: options.overrideDir ?? join(options.frameworkFleet, 'roles.local'),
});
const declaredProfiles = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
({ disposition }): boolean => disposition === 'canonical-profile',
).map(({ path }): string => basename(path, '.yaml'));
const resolvedProfiles = new Set(profiles.map(({ id }): string => id));
for (const profileId of declaredProfiles) {
if (!resolvedProfiles.has(profileId)) {
throw new Error(`declared canonical profile ${profileId} did not resolve`);
}
}
const servicePolicies = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
({ disposition }): boolean => disposition === 'canonical-service-policy',
);
for (const artifact of servicePolicies) {
const serviceProfile = await readInteractionServiceProfile(
join(options.frameworkFleet, artifact.path),
);
provisionInteractionService(serviceProfile, { agentName: 'interaction-example' });
}
return SHIPPED_FLEET_ARTIFACT_DISPOSITIONS;
}
async function assertEveryShippedArtifactIsDeclared(frameworkFleet: string): Promise<void> {
const declared = new Set(SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }): string => path));
const shipped = await listShippedFleetArtifactPaths(frameworkFleet);
for (const path of shipped) {
if (!declared.has(path)) {
throw new Error(`undeclared shipped fleet artifact: ${path}`);
}
}
for (const path of declared) {
if (!shipped.has(path)) {
throw new Error(`declared shipped fleet artifact is missing: ${path}`);
}
}
}
async function listShippedFleetArtifactPaths(frameworkFleet: string): Promise<Set<string>> {
const directories = ['examples', 'profiles', 'services'];
const paths = new Set<string>();
for (const directory of directories) {
const files = await readdir(join(frameworkFleet, directory));
for (const file of files) {
if (file.endsWith('.yaml') || file.endsWith('.yml')) paths.add(`${directory}/${file}`);
}
}
return paths;
}

View File

@@ -77,12 +77,25 @@ describe('readPersonaContractBlock — launch-time persona injection (A3b)', ()
}); });
it('injects an override-only (user-added) persona with no baseline at all', () => { 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'); seedOverride(home, 'mascot', '# Mascot\n\n(`class: mascot`)\n\nCUSTOM-ROLE.\n');
const block = readPersonaContractBlock(home, 'reviewer'); const block = readPersonaContractBlock(home, 'mascot');
expect(block).toContain('# Persona Contract (reviewer)'); expect(block).toContain('# Persona Contract (mascot)');
expect(block).toContain('CUSTOM-ROLE'); 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', () => { it('no-ops (empty string) when the class is undefined', () => {
seedBaseline(home, 'coder', BASELINE_CODER); seedBaseline(home, 'coder', BASELINE_CODER);
expect(readPersonaContractBlock(home, undefined)).toBe(''); expect(readPersonaContractBlock(home, undefined)).toBe('');

View File

@@ -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 { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { import {
ROSTER_V2_JSON_SCHEMA, ROSTER_V2_JSON_SCHEMA,
RosterV2ValidationError, RosterV2ValidationError,
parseRosterV2, parseRosterV2,
renderRosterV2Yaml, renderRosterV2Yaml,
validateRosterV2Semantics,
} from './roster-v2.js'; } from './roster-v2.js';
const validRoster = ` const validRoster = `
@@ -40,6 +43,127 @@ agents:
yolo: true 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 => { describe('roster v2 structural compiler', (): void => {
it('parses YAML into a normalized typed model and renders canonical YAML', (): void => { it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
const roster = parseRosterV2(validRoster, 'yaml'); const roster = parseRosterV2(validRoster, 'yaml');

View File

@@ -1,4 +1,15 @@
import YAML from 'yaml'; 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_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const; export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
@@ -58,6 +69,80 @@ export interface FleetRosterV2 {
readonly agents: readonly FleetRosterV2Agent[]; 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 { export class RosterV2ValidationError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import {
normalizeConnectorId,
normalizeConnectorScopes,
normalizeLeaseEpoch,
normalizeLogicalAgentIdentity,
normalizeLogicalBindingId,
type ConnectorExecutionContext,
type LogicalAgentIdentity,
} from './connector-lease.dto.js';
describe('logical agent connector lease contract', (): void => {
it('normalizes a runtime-neutral logical identity and binding vocabulary', (): void => {
const identity = normalizeLogicalAgentIdentity({
tenantId: ' tenant-01 ',
logicalAgentId: ' MOS.Primary ',
});
expect(identity).toEqual({ tenantId: 'tenant-01', logicalAgentId: 'mos.primary' });
expect(normalizeLogicalBindingId(' Discord:Operations ')).toBe('discord:operations');
expect(normalizeConnectorId(' PI.Worker-01 ')).toBe('pi.worker-01');
expect(Object.isFrozen(identity)).toBe(true);
expect(Object.keys(identity).sort()).toEqual(['logicalAgentId', 'tenantId']);
});
it('canonicalizes scopes and decimal fencing epochs', (): void => {
expect(normalizeConnectorScopes([' Runtime.Send ', 'tool.execute', 'runtime.send'])).toEqual([
'runtime.send',
'tool.execute',
]);
expect(normalizeLeaseEpoch('00042')).toBe('42');
});
it.each([
['', 'mos'],
['tenant', ''],
['tenant', 'claude session/123'],
])('rejects ambiguous identity values tenant=%j agent=%j', (tenantId, logicalAgentId): void => {
expect(() => normalizeLogicalAgentIdentity({ tenantId, logicalAgentId })).toThrow();
});
it('defines an adapter context without harness-native identity fields', (): void => {
const identity: LogicalAgentIdentity = { tenantId: 'tenant-01', logicalAgentId: 'mos' };
const context: ConnectorExecutionContext = {
identity,
bindingId: 'operator-chat',
connectorId: 'connector-a',
leaseId: '00000000-0000-4000-8000-000000000001',
leaseEpoch: '7',
scopes: ['runtime.send'],
correlationId: 'correlation-1',
grantExpiresAt: '2026-07-14T18:00:00.000Z',
};
expect(context).not.toHaveProperty('sessionId');
expect(context).not.toHaveProperty('tmuxSession');
expect(context).not.toHaveProperty('providerSessionId');
});
});

View File

@@ -0,0 +1,199 @@
const ID_PATTERN = /^[a-z0-9][a-z0-9._:@-]{0,127}$/;
const TENANT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/;
const SCOPE_PATTERN = /^[a-z][a-z0-9._:-]{0,127}$/;
/** Stable Mosaic identity. It intentionally contains no runtime/provider session identifier. */
export interface LogicalAgentIdentity {
readonly tenantId: string;
readonly logicalAgentId: string;
}
export interface LogicalAgentBinding {
readonly identity: LogicalAgentIdentity;
readonly bindingId: string;
}
export interface ConnectorLease extends LogicalAgentBinding {
readonly leaseId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly leaseEpoch: string;
readonly acquiredAt: string;
readonly heartbeatAt: string;
readonly expiresAt: string;
readonly releasedAt?: string;
}
export interface AcquireConnectorLeaseInput {
readonly identity: LogicalAgentIdentity;
readonly bindingId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly ttlMs: number;
readonly correlationId: string;
}
export interface TakeoverConnectorLeaseInput extends AcquireConnectorLeaseInput {
readonly expectedEpoch: string;
}
export interface HeartbeatConnectorLeaseInput {
readonly lease: ConnectorLease;
readonly ttlMs: number;
readonly correlationId: string;
}
export interface ReleaseConnectorLeaseInput {
readonly lease: ConnectorLease;
readonly correlationId: string;
}
export interface IssueConnectorExecutionGrantInput {
readonly lease: ConnectorLease;
readonly scopes: readonly string[];
readonly ttlMs: number;
readonly correlationId: string;
}
/** Internal server grant. Object provenance is checked in addition to these fields. */
export interface ConnectorExecutionGrant extends LogicalAgentBinding {
readonly leaseId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly leaseEpoch: string;
readonly issuedAt: string;
readonly expiresAt: string;
readonly correlationId: string;
}
/** Normalized context passed to an adapter only after current-lease validation. */
export interface ConnectorExecutionContext extends LogicalAgentBinding {
readonly leaseId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly leaseEpoch: string;
readonly correlationId: string;
readonly grantExpiresAt: string;
}
export interface FencedConnectorAdapter<TInput, TOutput> {
execute(input: TInput, context: ConnectorExecutionContext): Promise<TOutput>;
}
export type ConnectorLeaseAuditEventType =
| 'acquire'
| 'renew'
| 'takeover'
| 'reject'
| 'release'
| 'expiry';
export type ConnectorLeaseAuditOutcome = 'succeeded' | 'denied';
export type ConnectorLeaseRejectReason =
| 'policy_denied'
| 'lease_held'
| 'takeover_required'
| 'cas_mismatch'
| 'lease_missing'
| 'lease_released'
| 'lease_expired'
| 'stale_epoch'
| 'connector_mismatch'
| 'scope_denied'
| 'forged_grant'
| 'grant_expired';
/** Credential-safe metadata only: no grant object, scope set, payload, token, or approval ref. */
export interface ConnectorLeaseAuditEvent extends LogicalAgentBinding {
readonly event: ConnectorLeaseAuditEventType;
readonly outcome: ConnectorLeaseAuditOutcome;
readonly connectorId: string;
readonly correlationId: string;
readonly occurredAt: string;
readonly leaseId?: string;
readonly leaseEpoch?: string;
readonly reason?: ConnectorLeaseRejectReason;
}
export interface ConnectorLeaseAcquireMutation extends AcquireConnectorLeaseInput {
readonly leaseId: string;
readonly now: string;
readonly expiresAt: string;
}
export interface ConnectorLeaseTakeoverMutation extends TakeoverConnectorLeaseInput {
readonly leaseId: string;
readonly now: string;
readonly expiresAt: string;
}
export interface ConnectorLeaseHeartbeatMutation extends HeartbeatConnectorLeaseInput {
readonly now: string;
readonly expiresAt: string;
}
export interface ConnectorLeaseReleaseMutation extends ReleaseConnectorLeaseInput {
readonly now: string;
}
export interface ConnectorLeaseStore {
acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease>;
takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease>;
heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease>;
release(input: ConnectorLeaseReleaseMutation): Promise<void>;
findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null>;
recordAudit(event: ConnectorLeaseAuditEvent): Promise<void>;
}
export function normalizeLogicalAgentIdentity(input: LogicalAgentIdentity): LogicalAgentIdentity {
const tenantId = requiredIdentifier(input.tenantId, 'tenant ID', TENANT_PATTERN, false);
const logicalAgentId = requiredIdentifier(
input.logicalAgentId,
'logical agent ID',
ID_PATTERN,
true,
);
return Object.freeze({ tenantId, logicalAgentId });
}
export function normalizeLogicalBindingId(value: string): string {
return requiredIdentifier(value, 'logical binding ID', ID_PATTERN, true);
}
export function normalizeConnectorId(value: string): string {
return requiredIdentifier(value, 'connector ID', ID_PATTERN, true);
}
export function normalizeCorrelationId(value: string): string {
return requiredIdentifier(value, 'correlation ID', TENANT_PATTERN, false);
}
export function normalizeConnectorScope(value: string): string {
return requiredIdentifier(value, 'connector scope', SCOPE_PATTERN, true);
}
export function normalizeConnectorScopes(values: readonly string[]): readonly string[] {
if (values.length === 0) throw new Error('At least one connector scope is required');
return Object.freeze(Array.from(new Set(values.map(normalizeConnectorScope))).sort());
}
export function normalizeLeaseEpoch(value: string): string {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) throw new Error('Lease epoch must be a positive decimal integer');
const epoch = BigInt(trimmed);
if (epoch < 1n) throw new Error('Lease epoch must be a positive decimal integer');
return epoch.toString(10);
}
function requiredIdentifier(
value: string,
label: string,
pattern: RegExp,
lowerCase: boolean,
): string {
const trimmed = value.trim();
const normalized = lowerCase ? trimmed.toLowerCase() : trimmed;
if (!pattern.test(normalized)) {
throw new Error(`${label} has an invalid normalized format`);
}
return normalized;
}

View File

@@ -4,3 +4,4 @@ export interface AgentSessionHandle {
} }
export * from './agent-runtime-provider.js'; export * from './agent-runtime-provider.js';
export * from './connector-lease.dto.js';