Compare commits
4 Commits
67516adef4
...
test/758-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcb3f5a2ca | ||
| a5e8e55401 | |||
| eb4e14ae5c | |||
| 2e2280070a |
@@ -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,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
341
apps/gateway/src/agent/connector-lease.integration.test.ts
Normal file
341
apps/gateway/src/agent/connector-lease.integration.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
connectorLeaseAuditLog,
|
||||||
|
createDb,
|
||||||
|
eq,
|
||||||
|
logicalAgentConnectorLeases,
|
||||||
|
type DbHandle,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { ConnectorLeaseCoordinator } from '@mosaicstack/agent';
|
||||||
|
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||||
|
|
||||||
|
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||||
|
const tenantId = `lease-test-${randomUUID()}`;
|
||||||
|
const identity = { tenantId, logicalAgentId: 'mos' } as const;
|
||||||
|
|
||||||
|
describe.skipIf(!hasPostgres)('ConnectorLeaseRepository real PostgreSQL integration', (): void => {
|
||||||
|
let handle: DbHandle;
|
||||||
|
|
||||||
|
beforeAll((): void => {
|
||||||
|
handle = createDb(process.env['DATABASE_URL']);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async (): Promise<void> => {
|
||||||
|
if (!handle) return;
|
||||||
|
await handle.db
|
||||||
|
.delete(connectorLeaseAuditLog)
|
||||||
|
.where(eq(connectorLeaseAuditLog.tenantId, tenantId));
|
||||||
|
await handle.db
|
||||||
|
.delete(logicalAgentConnectorLeases)
|
||||||
|
.where(eq(logicalAgentConnectorLeases.tenantId, tenantId));
|
||||||
|
await handle.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the exclusive CAS fence across a real pool close/reopen', async (): Promise<void> => {
|
||||||
|
const command = {
|
||||||
|
identity,
|
||||||
|
bindingId: 'operator-chat',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 60_000,
|
||||||
|
} as const;
|
||||||
|
const firstCoordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
|
||||||
|
const contenders = await Promise.allSettled([
|
||||||
|
firstCoordinator.acquire({
|
||||||
|
...command,
|
||||||
|
connectorId: 'connector-a',
|
||||||
|
correlationId: 'postgres-acquire-a',
|
||||||
|
}),
|
||||||
|
firstCoordinator.acquire({
|
||||||
|
...command,
|
||||||
|
connectorId: 'connector-b',
|
||||||
|
correlationId: 'postgres-acquire-b',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const acquired = contenders.find((result) => result.status === 'fulfilled');
|
||||||
|
if (!acquired || acquired.status !== 'fulfilled') throw new Error('no lease contender won');
|
||||||
|
expect(contenders.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||||
|
|
||||||
|
await handle.close();
|
||||||
|
handle = createDb(process.env['DATABASE_URL']);
|
||||||
|
const reopened = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
|
||||||
|
const persisted = await reopened.current({ identity, bindingId: 'operator-chat' });
|
||||||
|
expect(persisted).toMatchObject({
|
||||||
|
leaseId: acquired.value.leaseId,
|
||||||
|
leaseEpoch: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const takeover = await reopened.takeover({
|
||||||
|
...command,
|
||||||
|
connectorId: 'connector-c',
|
||||||
|
correlationId: 'postgres-takeover',
|
||||||
|
expectedEpoch: acquired.value.leaseEpoch,
|
||||||
|
});
|
||||||
|
expect(takeover).toMatchObject({ connectorId: 'connector-c', leaseEpoch: '2' });
|
||||||
|
});
|
||||||
|
});
|
||||||
149
apps/gateway/src/agent/connector-lease.repository.test.ts
Normal file
149
apps/gateway/src/agent/connector-lease.repository.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
connectorLeaseAuditLog,
|
||||||
|
createPgliteDb,
|
||||||
|
eq,
|
||||||
|
runPgliteMigrations,
|
||||||
|
type DbHandle,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { ConnectorLeaseCoordinator, ConnectorLeaseError } from '@mosaicstack/agent';
|
||||||
|
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||||
|
|
||||||
|
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
|
||||||
|
|
||||||
|
function acquireCommand(connectorId: string, correlationId: string) {
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
bindingId: 'operator-chat',
|
||||||
|
connectorId,
|
||||||
|
scopes: ['runtime.send', 'tool.execute'],
|
||||||
|
ttlMs: 60_000,
|
||||||
|
correlationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConnectorLeaseRepository PostgreSQL semantics', (): void => {
|
||||||
|
let dataDir: string;
|
||||||
|
let handle: DbHandle;
|
||||||
|
let now: Date;
|
||||||
|
let coordinator: ConnectorLeaseCoordinator;
|
||||||
|
|
||||||
|
beforeEach(async (): Promise<void> => {
|
||||||
|
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-connector-lease-'));
|
||||||
|
handle = createPgliteDb(dataDir);
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
now = new Date('2026-07-14T17:00:00.000Z');
|
||||||
|
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
|
||||||
|
now: (): Date => now,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
await handle.close();
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows only one concurrent contender to acquire a binding', async (): Promise<void> => {
|
||||||
|
const outcomes = await Promise.allSettled([
|
||||||
|
coordinator.acquire(acquireCommand('connector-a', 'correlation-a')),
|
||||||
|
coordinator.acquire(acquireCommand('connector-b', 'correlation-b')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||||
|
const rejected = outcomes.find((result) => result.status === 'rejected');
|
||||||
|
expect(rejected).toMatchObject({
|
||||||
|
reason: { code: 'lease_held' } satisfies Partial<ConnectorLeaseError>,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise<void> => {
|
||||||
|
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
coordinator.takeover({
|
||||||
|
...acquireCommand('connector-b', 'correlation-b'),
|
||||||
|
expectedEpoch: acquired.leaseEpoch,
|
||||||
|
}),
|
||||||
|
coordinator.takeover({
|
||||||
|
...acquireCommand('connector-c', 'correlation-c'),
|
||||||
|
expectedEpoch: acquired.leaseEpoch,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const winner = results.find((result) => result.status === 'fulfilled');
|
||||||
|
|
||||||
|
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||||
|
expect(winner?.status === 'fulfilled' ? winner.value.leaseEpoch : null).toBe('2');
|
||||||
|
expect(results.find((result) => result.status === 'rejected')).toMatchObject({
|
||||||
|
reason: { code: 'cas_mismatch' } satisfies Partial<ConnectorLeaseError>,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('heartbeats and releases only the current connector epoch', async (): Promise<void> => {
|
||||||
|
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||||
|
now = new Date('2026-07-14T17:00:30.000Z');
|
||||||
|
const renewed = await coordinator.heartbeat({
|
||||||
|
lease: acquired,
|
||||||
|
ttlMs: 120_000,
|
||||||
|
correlationId: 'correlation-renew',
|
||||||
|
});
|
||||||
|
expect(renewed.expiresAt).toBe('2026-07-14T17:02:30.000Z');
|
||||||
|
|
||||||
|
await coordinator.release({ lease: renewed, correlationId: 'correlation-release' });
|
||||||
|
await expect(
|
||||||
|
coordinator.heartbeat({
|
||||||
|
lease: renewed,
|
||||||
|
ttlMs: 120_000,
|
||||||
|
correlationId: 'correlation-stale',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'lease_released' } satisfies Partial<ConnectorLeaseError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise<void> => {
|
||||||
|
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||||
|
await handle.close();
|
||||||
|
|
||||||
|
now = new Date('2026-07-14T17:02:00.000Z');
|
||||||
|
handle = createPgliteDb(dataDir);
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
|
||||||
|
now: (): Date => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordinator.acquire(acquireCommand('connector-b', 'correlation-plain-acquire')),
|
||||||
|
).rejects.toMatchObject({ code: 'takeover_required' } satisfies Partial<ConnectorLeaseError>);
|
||||||
|
const recovered = await coordinator.takeover({
|
||||||
|
...acquireCommand('connector-b', 'correlation-takeover'),
|
||||||
|
expectedEpoch: acquired.leaseEpoch,
|
||||||
|
});
|
||||||
|
expect(recovered).toMatchObject({ connectorId: 'connector-b', leaseEpoch: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes credential-safe lifecycle and rejection audit records', async (): Promise<void> => {
|
||||||
|
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||||
|
await coordinator.heartbeat({
|
||||||
|
lease: acquired,
|
||||||
|
ttlMs: 60_000,
|
||||||
|
correlationId: 'correlation-renew',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
coordinator.acquire(acquireCommand('connector-b', 'correlation-reject')),
|
||||||
|
).rejects.toBeInstanceOf(ConnectorLeaseError);
|
||||||
|
|
||||||
|
const rows = await handle.db
|
||||||
|
.select()
|
||||||
|
.from(connectorLeaseAuditLog)
|
||||||
|
.where(eq(connectorLeaseAuditLog.tenantId, identity.tenantId));
|
||||||
|
expect(rows.map((row) => row.event)).toEqual(
|
||||||
|
expect.arrayContaining(['acquire', 'renew', 'reject']),
|
||||||
|
);
|
||||||
|
const serialized = JSON.stringify(rows, (_key: string, value: unknown): unknown =>
|
||||||
|
typeof value === 'bigint' ? value.toString(10) : value,
|
||||||
|
);
|
||||||
|
expect(serialized).not.toContain('tool.execute');
|
||||||
|
expect(serialized).not.toContain('runtime.send');
|
||||||
|
expect(serialized).not.toMatch(/token|secret|credential/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
354
apps/gateway/src/agent/connector-lease.repository.ts
Normal file
354
apps/gateway/src/agent/connector-lease.repository.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
connectorLeaseAuditLog,
|
||||||
|
eq,
|
||||||
|
gt,
|
||||||
|
isNull,
|
||||||
|
logicalAgentConnectorLeases,
|
||||||
|
sql,
|
||||||
|
type Db,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { ConnectorLeaseError } from '@mosaicstack/agent';
|
||||||
|
import type {
|
||||||
|
ConnectorLease,
|
||||||
|
ConnectorLeaseAcquireMutation,
|
||||||
|
ConnectorLeaseAuditEvent,
|
||||||
|
ConnectorLeaseHeartbeatMutation,
|
||||||
|
ConnectorLeaseRejectReason,
|
||||||
|
ConnectorLeaseReleaseMutation,
|
||||||
|
ConnectorLeaseStore,
|
||||||
|
ConnectorLeaseTakeoverMutation,
|
||||||
|
LogicalAgentBinding,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
|
interface SuccessfulMutation {
|
||||||
|
readonly ok: true;
|
||||||
|
readonly lease: ConnectorLease;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FailedMutation {
|
||||||
|
readonly ok: false;
|
||||||
|
readonly reason: ConnectorLeaseRejectReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutationResult = SuccessfulMutation | FailedMutation;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConnectorLeaseRepository implements ConnectorLeaseStore {
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
async acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease> {
|
||||||
|
const result: MutationResult = await this.db.transaction(
|
||||||
|
async (tx): Promise<MutationResult> => {
|
||||||
|
const inserted = await tx
|
||||||
|
.insert(logicalAgentConnectorLeases)
|
||||||
|
.values({
|
||||||
|
leaseId: input.leaseId,
|
||||||
|
tenantId: input.identity.tenantId,
|
||||||
|
logicalAgentId: input.identity.logicalAgentId,
|
||||||
|
bindingId: input.bindingId,
|
||||||
|
connectorId: input.connectorId,
|
||||||
|
scopes: [...input.scopes],
|
||||||
|
leaseEpoch: 1n,
|
||||||
|
acquiredAt: new Date(input.now),
|
||||||
|
heartbeatAt: new Date(input.now),
|
||||||
|
expiresAt: new Date(input.expiresAt),
|
||||||
|
updatedAt: new Date(input.now),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
const row = inserted[0];
|
||||||
|
if (row) {
|
||||||
|
const lease = toLease(row);
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, lease, 'acquire'));
|
||||||
|
return { ok: true, lease };
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await findRow(tx, input);
|
||||||
|
if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||||
|
}
|
||||||
|
const reason: ConnectorLeaseRejectReason =
|
||||||
|
current && (current.releasedAt || current.expiresAt <= new Date(input.now))
|
||||||
|
? 'takeover_required'
|
||||||
|
: 'lease_held';
|
||||||
|
await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason));
|
||||||
|
return { ok: false, reason };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unwrap(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease> {
|
||||||
|
const result: MutationResult = await this.db.transaction(
|
||||||
|
async (tx): Promise<MutationResult> => {
|
||||||
|
const current = await findRow(tx, input);
|
||||||
|
if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) {
|
||||||
|
await insertAudit(
|
||||||
|
tx,
|
||||||
|
rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'),
|
||||||
|
);
|
||||||
|
return { ok: false, reason: 'cas_mismatch' };
|
||||||
|
}
|
||||||
|
if (current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||||
|
}
|
||||||
|
const updated = await tx
|
||||||
|
.update(logicalAgentConnectorLeases)
|
||||||
|
.set({
|
||||||
|
leaseId: input.leaseId,
|
||||||
|
connectorId: input.connectorId,
|
||||||
|
scopes: [...input.scopes],
|
||||||
|
leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`,
|
||||||
|
acquiredAt: new Date(input.now),
|
||||||
|
heartbeatAt: new Date(input.now),
|
||||||
|
expiresAt: new Date(input.expiresAt),
|
||||||
|
releasedAt: null,
|
||||||
|
updatedAt: new Date(input.now),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
bindingPredicate(input),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseId, current.leaseId),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
const row = updated[0];
|
||||||
|
if (!row) {
|
||||||
|
await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch'));
|
||||||
|
return { ok: false, reason: 'cas_mismatch' };
|
||||||
|
}
|
||||||
|
const lease = toLease(row);
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, lease, 'takeover'));
|
||||||
|
return { ok: true, lease };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unwrap(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease> {
|
||||||
|
const result: MutationResult = await this.db.transaction(
|
||||||
|
async (tx): Promise<MutationResult> => {
|
||||||
|
const updated = await tx
|
||||||
|
.update(logicalAgentConnectorLeases)
|
||||||
|
.set({
|
||||||
|
heartbeatAt: new Date(input.now),
|
||||||
|
expiresAt: new Date(input.expiresAt),
|
||||||
|
updatedAt: new Date(input.now),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
bindingPredicate(input.lease),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
||||||
|
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
||||||
|
isNull(logicalAgentConnectorLeases.releasedAt),
|
||||||
|
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
const row = updated[0];
|
||||||
|
if (row) {
|
||||||
|
const lease = toLease(row);
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, lease, 'renew'));
|
||||||
|
return { ok: true, lease };
|
||||||
|
}
|
||||||
|
const current = await findRow(tx, input.lease);
|
||||||
|
const reason = classifyAuthorityFailure(
|
||||||
|
current ? toLease(current) : null,
|
||||||
|
input.lease,
|
||||||
|
input.now,
|
||||||
|
);
|
||||||
|
if (reason === 'lease_expired' && current) {
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||||
|
}
|
||||||
|
await insertAudit(
|
||||||
|
tx,
|
||||||
|
rejectionAudit(
|
||||||
|
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
||||||
|
current ? toLease(current) : null,
|
||||||
|
reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { ok: false, reason };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unwrap(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async release(input: ConnectorLeaseReleaseMutation): Promise<void> {
|
||||||
|
const result: MutationResult = await this.db.transaction(
|
||||||
|
async (tx): Promise<MutationResult> => {
|
||||||
|
const updated = await tx
|
||||||
|
.update(logicalAgentConnectorLeases)
|
||||||
|
.set({
|
||||||
|
releasedAt: new Date(input.now),
|
||||||
|
expiresAt: new Date(input.now),
|
||||||
|
updatedAt: new Date(input.now),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
bindingPredicate(input.lease),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
||||||
|
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
||||||
|
isNull(logicalAgentConnectorLeases.releasedAt),
|
||||||
|
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
const row = updated[0];
|
||||||
|
if (row) {
|
||||||
|
const lease = toLease(row);
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, lease, 'release'));
|
||||||
|
return { ok: true, lease };
|
||||||
|
}
|
||||||
|
const current = await findRow(tx, input.lease);
|
||||||
|
const reason = classifyAuthorityFailure(
|
||||||
|
current ? toLease(current) : null,
|
||||||
|
input.lease,
|
||||||
|
input.now,
|
||||||
|
);
|
||||||
|
if (reason === 'lease_expired' && current) {
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||||
|
}
|
||||||
|
await insertAudit(
|
||||||
|
tx,
|
||||||
|
rejectionAudit(
|
||||||
|
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
||||||
|
current ? toLease(current) : null,
|
||||||
|
reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { ok: false, reason };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
unwrap(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
|
||||||
|
const row = await findRow(this.db, binding);
|
||||||
|
return row ? toLease(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||||
|
await insertAudit(this.db, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap(result: MutationResult): ConnectorLease {
|
||||||
|
if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason));
|
||||||
|
return result.lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeErrorMessage(reason: ConnectorLeaseRejectReason): string {
|
||||||
|
return `Connector lease mutation denied: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingPredicate(binding: LogicalAgentBinding) {
|
||||||
|
return and(
|
||||||
|
eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId),
|
||||||
|
eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId),
|
||||||
|
eq(logicalAgentConnectorLeases.bindingId, binding.bindingId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findRow(
|
||||||
|
db: Pick<Db, 'select'>,
|
||||||
|
binding: LogicalAgentBinding,
|
||||||
|
): Promise<typeof logicalAgentConnectorLeases.$inferSelect | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(logicalAgentConnectorLeases)
|
||||||
|
.where(bindingPredicate(binding))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease {
|
||||||
|
return Object.freeze({
|
||||||
|
identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }),
|
||||||
|
bindingId: row.bindingId,
|
||||||
|
leaseId: row.leaseId,
|
||||||
|
connectorId: row.connectorId,
|
||||||
|
scopes: Object.freeze([...row.scopes]),
|
||||||
|
leaseEpoch: row.leaseEpoch.toString(10),
|
||||||
|
acquiredAt: row.acquiredAt.toISOString(),
|
||||||
|
heartbeatAt: row.heartbeatAt.toISOString(),
|
||||||
|
expiresAt: row.expiresAt.toISOString(),
|
||||||
|
...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyAuthorityFailure(
|
||||||
|
current: ConnectorLease | null,
|
||||||
|
claimed: ConnectorLease,
|
||||||
|
now: string,
|
||||||
|
): ConnectorLeaseRejectReason {
|
||||||
|
if (!current) return 'lease_missing';
|
||||||
|
if (current.releasedAt) return 'lease_released';
|
||||||
|
if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired';
|
||||||
|
if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch';
|
||||||
|
return 'connector_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifecycleAudit(
|
||||||
|
input: { readonly correlationId: string; readonly now: string },
|
||||||
|
lease: ConnectorLease,
|
||||||
|
event: Exclude<ConnectorLeaseAuditEvent['event'], 'reject'>,
|
||||||
|
): ConnectorLeaseAuditEvent {
|
||||||
|
return {
|
||||||
|
identity: lease.identity,
|
||||||
|
bindingId: lease.bindingId,
|
||||||
|
connectorId: lease.connectorId,
|
||||||
|
leaseId: lease.leaseId,
|
||||||
|
leaseEpoch: lease.leaseEpoch,
|
||||||
|
event,
|
||||||
|
outcome: 'succeeded',
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
occurredAt: input.now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectionAudit(
|
||||||
|
input: {
|
||||||
|
readonly identity: ConnectorLease['identity'];
|
||||||
|
readonly bindingId: string;
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly now: string;
|
||||||
|
},
|
||||||
|
current: ConnectorLease | null,
|
||||||
|
reason: ConnectorLeaseRejectReason,
|
||||||
|
): ConnectorLeaseAuditEvent {
|
||||||
|
return {
|
||||||
|
identity: input.identity,
|
||||||
|
bindingId: input.bindingId,
|
||||||
|
connectorId: input.connectorId,
|
||||||
|
event: 'reject',
|
||||||
|
outcome: 'denied',
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
occurredAt: input.now,
|
||||||
|
...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}),
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertAudit(db: Pick<Db, 'insert'>, event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||||
|
await db.insert(connectorLeaseAuditLog).values({
|
||||||
|
tenantId: event.identity.tenantId,
|
||||||
|
logicalAgentId: event.identity.logicalAgentId,
|
||||||
|
bindingId: event.bindingId,
|
||||||
|
connectorId: event.connectorId,
|
||||||
|
...(event.leaseId ? { leaseId: event.leaseId } : {}),
|
||||||
|
...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}),
|
||||||
|
event: event.event,
|
||||||
|
outcome: event.outcome,
|
||||||
|
...(event.reason ? { reason: event.reason } : {}),
|
||||||
|
correlationId: event.correlationId,
|
||||||
|
occurredAt: new Date(event.occurredAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
285
apps/gateway/src/agent/connector-lease.service.ts
Normal file
285
apps/gateway/src/agent/connector-lease.service.ts
Normal 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];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
31
docs/PRD.md
31
docs/PRD.md
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
49
docs/architecture/mos-runtime-portability-m1.md
Normal file
49
docs/architecture/mos-runtime-portability-m1.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The normalized identity is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(tenant_id, logical_agent_id, binding_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
`logical_agent_id` is a server-owned stable identifier. A connector is a replaceable holder of a lease for one binding; it is not the agent identity.
|
||||||
|
|
||||||
|
## Durable lease model
|
||||||
|
|
||||||
|
PostgreSQL table `logical_agent_connector_leases` has one unique row per identity/binding tuple. The current row records:
|
||||||
|
|
||||||
|
- an opaque lease UUID;
|
||||||
|
- connector ID and normalized allowed scopes;
|
||||||
|
- a positive decimal fencing epoch stored as PostgreSQL `bigint`;
|
||||||
|
- acquired, heartbeat, expiry, release, and update timestamps.
|
||||||
|
|
||||||
|
Initial acquisition is insert-only. An existing active row causes `lease_held`. An expired or released row causes `takeover_required`; ordinary acquisition cannot recover it. Authorized takeover uses compare-and-swap against the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch.
|
||||||
|
|
||||||
|
The companion `connector_lease_audit_log` is append-only metadata. It stores lifecycle event, outcome/reason, identity/binding/connector, epoch, correlation ID, and timestamp. It deliberately excludes scopes, grant objects, payloads, approval references, tokens, and credentials.
|
||||||
|
|
||||||
|
## Execution grants
|
||||||
|
|
||||||
|
`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. Defense-in-depth caps leases at 5 minutes and grants at 30 seconds by default; constructor options may tighten these limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, and epoch.
|
||||||
|
|
||||||
|
Validation occurs immediately before adapter invocation and rereads PostgreSQL. The adapter receives only `ConnectorExecutionContext`; harness-native schemas remain behind the adapter. Validation denies:
|
||||||
|
|
||||||
|
- grants not minted by the current gateway process (including cloned/forged objects);
|
||||||
|
- expired grants or leases;
|
||||||
|
- released leases;
|
||||||
|
- stale epochs or replaced connector/lease UUIDs;
|
||||||
|
- missing/cross-tenant/cross-agent/cross-binding leases;
|
||||||
|
- scopes not authorized by both grant and current lease.
|
||||||
|
|
||||||
|
A gateway restart intentionally invalidates process-local grants. The durable lease and epoch survive, and a fresh grant may be issued only after current-lease and gateway-policy validation.
|
||||||
|
|
||||||
|
## Concurrency and side-effect rule
|
||||||
|
|
||||||
|
The database CAS determines the sole current holder. A successful takeover makes every old-epoch validation fail. Connector adapters must consume and propagate the normalized lease epoch/context so downstream effect boundaries can also fence races that occur after gateway validation.
|
||||||
|
|
||||||
|
M1 does not provide exactly-once receipts or a side-effect journal. Those remain later #754 work; callers must not infer exactly-once delivery from lease fencing.
|
||||||
|
|
||||||
|
## Extension boundary
|
||||||
|
|
||||||
|
`ConnectorLeaseService` is the gateway-owned policy surface. Every policy decision receives the normalized requested scopes and TTL (or explicit `null` where no TTL applies), so a concrete policy can enforce least privilege and duration limits. Its production default policy denies every lease/grant operation until a server-configured connector policy is supplied. No M1 HTTP endpoint accepts caller-controlled tenant or logical identity, and no concrete Claude/Pi/Codex adapter or channel cutover is included.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
52
docs/fleet/migration/example-profile-disposition.md
Normal file
52
docs/fleet/migration/example-profile-disposition.md
Normal 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.
|
||||||
43
docs/guides/mos-connector-lease-operations.md
Normal file
43
docs/guides/mos-connector-lease-operations.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Mos Connector Lease Operations — M1
|
||||||
|
|
||||||
|
## Operational status
|
||||||
|
|
||||||
|
M1 installs the durable schema and gateway policy/adapter boundary. It does **not** activate a connector, expose a lease administration endpoint, or cut over a channel. The default gateway connector-lease policy is deny-all until a later work package supplies an authorized server-side policy and concrete adapter.
|
||||||
|
|
||||||
|
## Events to monitor
|
||||||
|
|
||||||
|
Use correlation IDs to follow `connector_lease_audit_log` events:
|
||||||
|
|
||||||
|
| Event | Meaning |
|
||||||
|
| ---------- | --------------------------------------------------------------------- |
|
||||||
|
| `acquire` | First holder inserted for an unused binding |
|
||||||
|
| `renew` | Current holder heartbeat extended the TTL |
|
||||||
|
| `takeover` | Authorized CAS replaced the holder and incremented epoch |
|
||||||
|
| `release` | Current holder explicitly relinquished authority |
|
||||||
|
| `expiry` | An expired current lease was observed |
|
||||||
|
| `reject` | Policy, CAS, expiry, scope, or fencing validation denied an operation |
|
||||||
|
|
||||||
|
Audit data is metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output.
|
||||||
|
|
||||||
|
## Incident checks
|
||||||
|
|
||||||
|
For suspected duplicate/stale connector effects:
|
||||||
|
|
||||||
|
1. Correlate the attempted operation with its `reject`, `takeover`, or `expiry` event.
|
||||||
|
2. Compare the current row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context.
|
||||||
|
3. Treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative. Do not retry it as the old holder.
|
||||||
|
4. Recovery uses the authorized takeover path with the observed expected epoch. Ordinary acquire is intentionally rejected for expired/released rows.
|
||||||
|
5. If an external effect may already have happened, preserve evidence and do not assume lease fencing provides exactly-once replay safety.
|
||||||
|
|
||||||
|
## Migration and rollback safety
|
||||||
|
|
||||||
|
Migration `0016_salty_morlocks.sql` is additive: it creates two new tables and indexes without modifying existing authorization/session tables. Before rollout, normal database backup and migration verification still apply. Rolling application code back leaves unused additive tables in place; dropping tables is not part of automated rollback because it would destroy lease/audit evidence.
|
||||||
|
|
||||||
|
## Security constraints
|
||||||
|
|
||||||
|
- Tenant comes from authenticated gateway context, never a connector request field.
|
||||||
|
- Logical agent, binding, connector, and scope identifiers use normalized constrained forms.
|
||||||
|
- Takeover requires explicit gateway policy authorization and an expected epoch.
|
||||||
|
- Default defense-in-depth TTL caps are 5 minutes for leases and 30 seconds for grants; policy may enforce stricter limits.
|
||||||
|
- Validation and rejection audit complete before adapter side effects.
|
||||||
|
- Existing authz and exact-action approval controls remain additional required gates; a valid connector lease does not bypass them.
|
||||||
415
docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md
Normal file
415
docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# KBN-010 — Threat, Authorization, and Constraint-Impact Gate
|
||||||
|
|
||||||
|
- **Issue:** [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753)
|
||||||
|
- **Gate status:** **PASS / GO**
|
||||||
|
- **Reviewed baseline:** `origin/main` at `49e8a54` (2026-07-14)
|
||||||
|
- **Frozen target:** `SHARED-CONTRACT.md` v1.0.0-rc.4 and `contracts/*.v1.ts`
|
||||||
|
- **Disposition input:** contract commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`
|
||||||
|
- **Scope:** documentation and future-test planning only; no runtime, schema, migration, API, configuration, dependency, CI, or deployment change
|
||||||
|
|
||||||
|
## 1. Decision
|
||||||
|
|
||||||
|
KBN-010 is **PASS / GO** against frozen contract rc.4. The original rc.3 finding remains historical detection evidence:
|
||||||
|
|
||||||
|
- **KBN010-SI-001 — rc.3 invalid mission composite-FK candidate key.** At rc.3, `missionsV1` declared a primary key on `id` and a unique key on `(workspace_id, project_id, id)`, but not a candidate key on `(workspace_id, id)`. Both `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` referenced exactly `(missions.workspace_id, missions.id)`. PostgreSQL requires the referenced column list of a foreign key to match a non-partial unique/primary candidate key; uniqueness of `id` alone did not satisfy that two-column reference. The rc.3 DDL was therefore invalid, and KBN-010 correctly blocked it.
|
||||||
|
|
||||||
|
Contract rc.4 resolves SI-001 by adding the non-partial `missions_workspace_id_uidx` candidate key on `(workspace_id, id)` while retaining the global `id` primary key and the project-congruent `(workspace_id, project_id, id)` key. Both polymorphic child FKs retain their exact workspace-safe ordered columns and `ON DELETE RESTRICT`; no target, tenancy, project-congruence, exactly-one-target, N-1, rollback, no-cascade, identity, approval, or fencing authority is weakened.
|
||||||
|
|
||||||
|
Independent Homelab non-author schema/security review returned **APPROVE** for the exact rc.4 commit/tree/content and found no collision with #757 connector fencing. SI-001 has no unresolved contract/schema-design impact.
|
||||||
|
|
||||||
|
This GO completes the KBN-010 analysis/review prerequisite only. It does **not** claim that runtime schema or migration DDL exists. KBN-100 remains held and may be released only after this PR squash-merges, the merged change reaches terminal-green CI on `main`, and issue #753 closes.
|
||||||
|
|
||||||
|
### 1.1 Independent rc.4 evidence identity
|
||||||
|
|
||||||
|
- **Commit:** `3f6a3387b419eb99453ee10dd25ba888faaab0b5`
|
||||||
|
- **Tree:** `7ebab8fa530a7180036928cea9527f808548aa14`
|
||||||
|
- **Stable full-index SHA-256:** `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`
|
||||||
|
- **Stable patch-id:** `058cf98026fcd1043703c866aee047c8bb144740`
|
||||||
|
- **Verdict:** Homelab independent non-author schema/security review **APPROVE**.
|
||||||
|
- **Reviewed conclusions:** the candidate key repairs both dependent FKs; tenant safety, polymorphic exactly-one-target semantics, RESTRICT/no-cascade behavior, and N-1/rollback semantics remain valid; #757 uses separate tables/indexes/FKs/identity/fence authority and has no collision.
|
||||||
|
|
||||||
|
A command-rendered patch SHA may differ when Git rendering options, headers, or command form differ. That rendering digest is non-authoritative. Canonical review identity is the Git commit object plus tree and exact file content; the stable full-index digest and stable patch-id above are corroborating identities.
|
||||||
|
|
||||||
|
## 2. Method and trust boundaries
|
||||||
|
|
||||||
|
### 2.1 Inputs inspected
|
||||||
|
|
||||||
|
- Canonical requirements: `docs/requirements/native-kanban-sot.md`.
|
||||||
|
- Workstream manifest and read-only task plan.
|
||||||
|
- Frozen health, schema, Mechanical Coordinator, and recovery contracts in full.
|
||||||
|
- Actual current-main schema, Better Auth guard/scope helpers, project/task/mission/team controllers and repositories, fleet backlog, and `TASKS.md` parser/writer.
|
||||||
|
- Issue #753 through the Mosaic provider wrapper.
|
||||||
|
|
||||||
|
### 2.2 Current-main exposure that the target must replace, not inherit
|
||||||
|
|
||||||
|
| Current-main fact | Constraint on future implementation |
|
||||||
|
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Teams are global; projects, missions, tasks, agents, and fleet backlog have no `workspace_id`. | KBN-100 must add the workspace boundary and KBN-110 must query by server-derived workspace in every repository operation. |
|
||||||
|
| `AuthGuard` authenticates a Better Auth user, while `scopeFromUser` falls back through optional tenant/team/org claims and finally user ID. | Kanban tenancy must derive from an authenticated **active workspace membership**, not this compatibility fallback or caller data. |
|
||||||
|
| Team list/get/member endpoints return global team data to any authenticated user. | New Kanban endpoints must use a uniform no-oracle denial and must not reuse global team lookup as authorization. |
|
||||||
|
| Project/task repositories load and mutate by bare IDs; controller checks are separate and sometimes distinguish not-found from forbidden. | Workspace predicates and authorization must be inside the authoritative transaction/repository command path. |
|
||||||
|
| Tasks can have nullable project/mission links, free-text assignee, JSON tags, no aggregate version, and no fence. | Expand/backfill/quarantine must precede NOT NULL/composite constraints; new commands cannot trust legacy fields. |
|
||||||
|
| `mission_tasks.status` is a second status writer. | Pre-expand must prohibit it as a write source and later retire it only after N-1 evidence. |
|
||||||
|
| Fleet `backlog` has global JSON dependencies and TTL claims without workspace, assignment, approval, session, or fencing. | It must be frozen and imported as non-dispatching shadow data; it cannot be adapted into the canonical lease path. |
|
||||||
|
| `packages/coord/src/tasks-file.ts` parses and mutates `TASKS.md`. | KBN-120 must replace production use with generated, read-only projection code and prove there is no import/mutation path. |
|
||||||
|
| No Kanban transaction-local health proof, semantic audit/event chain, change proposals, canonical outbox, approval binding, or fenced lease model exists. | These are new frozen invariants, not behaviors that may be inferred from current endpoints. |
|
||||||
|
|
||||||
|
## 3. Authorization matrix
|
||||||
|
|
||||||
|
The exact route/DTO freeze belongs to KBN-105. This matrix fixes the minimum authorization behavior that freeze and later implementation must preserve.
|
||||||
|
|
||||||
|
| Principal/state | Permitted authority | Required authoritative checks | Explicit denials |
|
||||||
|
| -------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Unauthenticated caller | Public health observation only, if deployment exposes it | Health DTO validation; no proof field accepted | All canonical reads/mutations; health observation never authorizes a write |
|
||||||
|
| Active workspace `owner`/`admin` user | Policy-allowed workspace administration and domain commands | Better Auth session; active membership; server-derived workspace; command-family role; expected version/idempotency | Foreign workspace, suspended workspace, revoked membership, caller workspace override |
|
||||||
|
| Active workspace `member` user | Policy-allowed project/task/proposal commands | Active membership plus project/team capability and target checks in the same transaction | Admin, approval, purge, service-only Coordinator, and unrelated project commands |
|
||||||
|
| Active workspace `auditor` user | Workspace-scoped reads and audit/evidence inspection | Active membership and read capability | Every mutation, approval, lease, token issuance, purge |
|
||||||
|
| Active workspace `service` identity | Only explicitly issued command families | Credential maps to workspace+agent+session; agent enabled; session live; role/capability allowlist; token expiry/audience; DB recheck per command | Raw DB credentials, user/admin fallback, cross-workspace scope, command families absent from token and registry |
|
||||||
|
| Enabled agent with live session | Agent commands matching its declared and policy-approved specialist role/capabilities | Exact workspace+agent+session binding, heartbeat/state, assignment target, lease, current decimal-string fence | Ended/offline/degraded session where policy disallows; disabled agent; another assignment/session/fence |
|
||||||
|
| Mechanical Coordinator engine | Pure eligibility/order/expiry decisions from immutable snapshots | Complete workspace-local snapshot and policy revision | Authentication, ID loading, SQL, proof minting, scope invention, approval, certification, merge |
|
||||||
|
| Coordinator persistence service | Service-only assignment/lease/checkpoint/recovery commands | Fresh transaction-local proof; locks; current assignment/approval/task/session/policy/fence | Public/user proof-by-value, stale approval/policy, direct completion/certification/merge |
|
||||||
|
| Reviewer/SecReview/Certifier | Attributable evidence decisions allowed by gate policy | Active authority, author differs from reviewer, mandatory SecReview classification, immutable artifacts | Self-review; missing evidence; Certifier merge/issue-close/release |
|
||||||
|
| Break-glass retention operator | Narrow, time-bounded purge procedure only | Separate break-glass authority, reason, scope, approvals, immutable pre-purge evidence, semantic audit, post-action reconciliation | Normal application role DELETE/UPDATE, bulk unscoped purge, unaudited hard delete |
|
||||||
|
| Revoked/expired/disabled identity or ended session | None beyond policy-permitted public observation | Revocation/lifecycle checked from PostgreSQL on every command | Cached token/Valkey state cannot preserve authority |
|
||||||
|
|
||||||
|
**No-oracle rule:** authentication may return 401, but once authenticated, a foreign-workspace, nonexistent, inaccessible, or wrong-project identifier must follow the one KBN-105-frozen 404/403 policy with the same response shape and no foreign metadata, timing-derived detail, or WebSocket/MCP discrepancy.
|
||||||
|
|
||||||
|
## 4. Threat matrix
|
||||||
|
|
||||||
|
Every disposition is against the frozen target, not a claim about current-main behavior.
|
||||||
|
|
||||||
|
| ID | Attacker or failure | Asset | Precondition and abuse path | Frozen preventive/detective control | Required schema/API/negative-test evidence | Future owner | Residual risk | Disposition |
|
||||||
|
| --- | --------------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||||
|
| T01 | Authenticated user supplies a foreign workspace/resource ID | Tenant confidentiality and integrity | Caller knows or guesses project/task/mission/team IDs and probes REST, MCP, WebSocket, repository, or Coordinator paths | `workspace_id` on every canonical row; composite relations; server-derived tenant; uniform no-oracle denial | Composite FK/unique DDL; every repository predicate includes workspace; N100-01/02, N110-01..05, N130-01 | KBN-100, 105, 110, 130 | Timing/volume side channels require operational review | Controlled after evidence |
|
||||||
|
| T02 | Revoked or inactive member retains an old session | Ownership and mutation authority | Authentication remains valid after workspace membership revocation | Active membership rechecked in the authoritative transaction for owners, principals, proposers, and decision actors | Active/inactive membership fixtures; N100-03, N110-06/07; no cached membership authority | KBN-100, 110 | Better Auth session may remain valid for unrelated features | Controlled after evidence |
|
||||||
|
| T03 | User joins/forges a team relation outside its workspace | Team-owned projects and tasks | Global-current-main team behavior or a stale membership is reused | Team is intra-workspace only; workspace/team composites; active workspace membership precedes team authorization | Cross-workspace team/member/owner insert and command denials; N100-04/05, N110-08 | KBN-100, 110 | Team-role policy mistakes remain possible | Controlled after evidence |
|
||||||
|
| T04 | Same-workspace IDs from a different project are combined | Planning hierarchy integrity | Valid mission/milestone/parent/current-milestone UUIDs are substituted | Project-congruent composite relations and serialized hierarchy validation | Mission/milestone/parent/current milestone mismatch and parent-cycle tests; N100-06..10, N110-09 | KBN-100, 110 | Deep hierarchy checks can be expensive | Controlled after evidence |
|
||||||
|
| T05 | Foreign or unrelated evidence/link/artifact IDs are attached | Review and audit truth | Caller has a valid same-workspace or foreign artifact UUID | Workspace-aware joins; immutable artifact digest/revision; semantic same-target validation in authoritative transaction | Mixed-workspace and same-workspace wrong-task/mission checkpoint/approval evidence tests; N100-11..14, N210-15/16 | KBN-100, 110, 210 | Same-workspace semantic validation is application-enforced | Controlled after evidence |
|
||||||
|
| T06 | Stolen, over-scoped, or replayed service token | Coordinator and task mutation authority | Service credential is accepted as admin/user or claims are trusted without DB state | Command-family least privilege; agent/session workspace binding; no raw DB credentials; enabled/live state checked per command | Auth registry fixtures prove audience/expiry/role/capability; revoked agent and ended session denials; N105-01, N110-10..13, N210-01/02 | KBN-105, 110, 210 | Credential theft until expiry/revocation check | Controlled after evidence |
|
||||||
|
| T07 | Caller forges public `healthy` or replays a stale health response | Sole-writer/fail-closed invariant | Public health body or caller field reaches mutation context | Public DTO is observation only; public DTOs reject proof/health fields; Gateway mints internal proof after live PG transaction probe | Contradictory union and forbidden-field tests; N105-02, N110-14..17 | KBN-105, 110, 140 | Health endpoint can still be used for reconnaissance | Controlled after evidence |
|
||||||
|
| T08 | Internal stale, wrong-policy, or wrong-transaction proof is reused | Transaction integrity | A branded value leaks or an adapter fails to revalidate it | Non-exported brand; transaction identity, `checkedAt <= now < validUntil`, and policy revision revalidated immediately before mutation | Wrong transaction, expiry boundary, future timestamp, policy mismatch, commit-after-expiry tests; N110-18..22 | KBN-110, 140 | In-process code can bypass TypeScript; runtime checks are mandatory | Controlled after evidence |
|
||||||
|
| T09 | DB/transport uncertainty is mislabeled as deliberate denial or conflict | Safe retry and exactly-once result | Timeout occurs before/after commit and client changes key or retries 503 | Exact 503/502/504/timeout/409 union; unknown outcome retries only with same idempotency key | Exhaustive fixture mapping and commit-before-timeout replay; N105-03, N110-23..27, N120-01/02 | KBN-105, 110, 120, 140 | External client may ignore retry rules | Controlled after evidence |
|
||||||
|
| T10 | Assignment payload forges task version, target agent/session, role, expiry, or proposer | Work routing authority | Lease service trusts command DTO rather than persisted assignment | Persisted assignment identity; exactly-one principal/proposer; exact agent/session composite; acquire accepts IDs then reloads+locks | Cross-workspace and same-workspace target substitutions, stale task version, invalid role, expired assignment; N100-15..18, N210-03..08 | KBN-100, 200, 210 | Compromised authorized proposer can make harmful proposals | Controlled by approval/audit |
|
||||||
|
| T11 | Approval proof is forged by value or borrowed from another assignment | Gate integrity | Caller submits `approved=true`, unrelated decision ID, stale policy, or self-approval | Relational approval bound to assignment; lock/reload; policy revision; author≠reviewer and mandatory SecReview | No proof-by-value DTO; wrong assignment/task/workspace/policy/actor/decision tests; N105-04, N210-09..14, N230-01 | KBN-105, 210, 230 | Colluding principals remain an organizational risk | Controlled after evidence |
|
||||||
|
| T12 | Revoked policy or expired proposal/assignment is raced against lease acquisition | Routing policy | Approval and lease transactions do not lock/revalidate current rows | Lock assignment, approval, task, target session; compare current policy and expiry inside fresh-proof transaction | Concurrent revoke/expire/acquire tests with one valid terminal result; N210-17..19 | KBN-210, 230 | Clock skew if DB time is not canonical | Controlled after evidence |
|
||||||
|
| T13 | Stale worker sends ack/heartbeat/checkpoint/review after reassignment | Canonical task and evidence state | Old process retains task/session IDs | Task-row-locked atomic monotonic bigint fence; every worker command carries exact lease/session/fence | Lower, expired, future, and other-task fences denied; old worker loses after new lease; N100-19/20, N210-20..24 | KBN-100, 210, 230 | Signed bigint exhaustion is theoretical | Controlled after evidence |
|
||||||
|
| T14 | JavaScript precision truncates a fence | Stale-worker exclusion | bigint token is serialized as number above `2^53-1` | Drizzle bigint and decimal-string wire type only | `9007199254740993` and near-`int8` boundary round trips; numeric JSON rejected; N105-05, N210-25 | KBN-105, 210 | Nonconforming external clients | Controlled after evidence |
|
||||||
|
| T15 | Checkpoint/evidence from another lease/task/session is submitted | Recovery and certification evidence | Same-workspace valid IDs are mixed | Exact lease composite binds workspace+task+assignment/session+fence; checkpoint composite binds lease+fence; evidence join plus semantic artifact-owner check | Same-workspace mismatched task/assignment/lease/session/checkpoint/artifact tests; N100-21..23, N210-26..31 | KBN-100, 210 | Artifact URI target may disappear outside DB | Controlled with digest/retention |
|
||||||
|
| T16 | Outage note or pending/rejected proposal mutates/orders work | Sole SOT and gate integrity | Importer/UI treats note/proposal as task state | Proposals are inert; only explicit accept invokes normal typed command after recovery | Row/outbox/task counts unchanged for pending/rejected; no readiness/dependency/lease effect; N110-28..31 | KBN-110, 140 | Humans may act outside Mosaic operationally | Accepted as attributable residual |
|
||||||
|
| T17 | Submission event is missing, foreign, or for another proposal | Proposal audit chain | Caller supplies an existing event UUID | Preallocated proposal ID; event-first same transaction; workspace composite FK; exact event type/aggregate/version semantic check | Missing/foreign/wrong-type/wrong-proposal event rolls back event+proposal; N100-24/25, N110-32..36 | KBN-100, 110 | Semantic checks are transaction code, not only FK | Controlled after evidence |
|
||||||
|
| T18 | Acceptance borrows an unrelated command event | Proposal and target integrity | Same-workspace event exists for another target/command/proposal | Accept locks proposal+target, executes normal command, requires workspace/target match, causation=submission event, payload proposal ID | Foreign, wrong target/type/command/causation/payload event aborts target/event/proposal atomically; N100-26, N110-37..43 | KBN-100, 110 | Event payload schema drift | Controlled by KBN-105 fixtures |
|
||||||
|
| T19 | Application role updates/deletes audit, approval evidence, checkpoint, or artifact | Nonrepudiation | Broad DB grants or parent cascade exists | INSERT/SELECT-only application roles; RESTRICT parent deletes; archive/cancel normal lifecycle | Role-level UPDATE/DELETE denied; parent delete RESTRICT; digest unchanged; N100-27..31 | KBN-100 | DB superuser can alter state | Break-glass/infra audit residual |
|
||||||
|
| T20 | Break-glass purge is used as routine deletion or erases its own evidence | Retention and incident forensics | Elevated credential available | Separate audited retention procedure, bounded scope, reason, pre/post evidence, authority separation | Normal role denied; expired/missing approval denied; purge cannot delete its authorizing audit package; N115-01, N230-02/03 | KBN-115, 230 | Privileged DBA compromise | Accepted operational residual |
|
||||||
|
| T21 | PostgreSQL unavailable or partitioned | Canonical state | Public health/Valkey remains live while transaction probe fails | Fail closed; no alternate writer/hidden queue; 503 only for proven not-applied; transport uncertainty remains unknown | Fault injection proves DB rows/outbox/files/Valkey unchanged on deliberate denial; commit-unknown replay; N110-44..48, N140-01 | KBN-110, 140, 230 | Availability loss is intentional | Accepted by Option A |
|
||||||
|
| T22 | Valkey unavailable, duplicated, stale, or partitioned | Scheduling notifications | Queue wake is treated as truth or publication fails | Valkey derived/expendable; transactional outbox in PG; idempotent publisher; recovery from PG | Commit with Valkey down leaves pending outbox; replay publishes once logically; stale wake reloads PG; N110-49, N140-02, N230-04..06 | KBN-110, 210, 230 | Duplicate at-least-once delivery | Consumers must be idempotent |
|
||||||
|
| T23 | Coordinator restarts between assignment, lease, checkpoint, or outbox steps | Durable orchestration truth | Process-local cache is treated as authority | PostgreSQL stores assignments, execution state, leases, fences, checkpoints, events, outbox; `recoverFromPostgres` | Restart at every transaction boundary reconstructs identical active/expired/pending sets without Valkey/files; N210-32..36, N230-07 | KBN-210, 230 | Recovery latency | Controlled after evidence |
|
||||||
|
| T24 | Dependency cycle or concurrent reciprocal edge | Readiness and dispatch safety | Two transactions each see an acyclic graph before inserting | Unique directed edge; no self-edge; serialized recursive cycle check; readiness evaluates all blockers | Self/duplicate/cycle and concurrent A→B/B→A tests; all predecessor property test; N100-32..35, N200-01/02 | KBN-100, 200, 230 | Very large DAG performance | Bounded operational residual |
|
||||||
|
| T25 | Parent-task cycle or project-incongruent relation | Planning hierarchy | Valid same-workspace IDs are arranged into an invalid tree | Project-congruent composites; serialized parent-cycle/orphan validation required by REQ-PLAN-001 | Self/indirect parent cycle, orphan, and cross-project mission/milestone/parent tests; N100-06..10 | KBN-100, 110 | Cycle validation is service/transaction enforced | Controlled after evidence |
|
||||||
|
| T26 | Concurrent update, duplicate retry, or idempotency payload drift | Aggregate consistency | Two clients use same version/key with different payloads | Expected-version check; semantic event and outbox in same transaction; key returns prior immutable result only for identical command | One update wins; stale gets 409; duplicate identical returns prior; payload drift rejected; N110-50..54, N140-03 | KBN-105, 110, 140 | Long-lived clients face visible conflicts | Intentional user-visible residual |
|
||||||
|
| T27 | State/event/outbox partial commit | Audit and notification consistency | Separate transactions or exception after state write | One PostgreSQL transaction for state+semantic event+outbox | Failure injected after each insert rolls all three back; success revisions align; N110-55..58 | KBN-110, 140 | Outbox publication remains asynchronous | Controlled after evidence |
|
||||||
|
| T28 | Malicious/incorrect importer injects foreign workspace data or dispatchable work | Migration integrity | Source keys collide, lineage is absent, or importer has direct DB authority | Immutable source snapshots/checksums; one-way Gateway/migration-only port; workspace-safe idempotent modes; shadow records cannot dispatch | Foreign/malformed/duplicate/partial-resume/lineage checksum and no-dispatch tests; N300-01..08 | KBN-300, 330 | Source data may be semantically ambiguous | Quarantine and owner sign-off |
|
||||||
|
| T29 | Cutover leaves legacy writer or forward/reverse sync active | Sole-writer invariant | Credentials/processes survive switch or rollback is improvised | Writer inventory, freeze, final delta, Gateway switch, credential shutdown, no dual write; rollback authority changes after first DB mutation | Process/credential inventory; concurrent-writer assertion; before/after-mutation rollback rehearsal; N320-01..06, N330-01 | KBN-320, 330, 340 | Missed external automation | Owner-gated residual |
|
||||||
|
| T30 | Generated `TASKS.md`/`mission.json` is edited or parsed into DB | Canonical state | Current-main parser/writer remains reachable or file watcher imports changes | Generated non-authoritative header/IDs/time/revision; no production importer; regenerate/overwrite only | Static import search, tamper/regeneration, read-only permission, source-revision parity; N120-03..07, N140-04 | KBN-120, 140 | Humans may mistake snapshots for live data | Header and docs mitigate |
|
||||||
|
| T31 | N-1 compatibility copies legacy ambiguity into canonical authority | Data integrity | Nullable/global/current-main fields are guessed during backfill | Nullable-first expand; deterministic mapping or quarantine; checksums; no new-only status before switch; legacy fields retained | Production-shape, ambiguous owner/assignee, status shadow, JSON/config/digest, rollback tests; N100-36..44 | KBN-100 | Quarantined records require human decision | Controlled by signed reconciliation |
|
||||||
|
| T32 | Recovery posture claims durability not provided by mechanisms | Availability and audit retention | Shape-only validation or optimistic RPO is accepted | Normative validator; WAL/PITR/RPO/storage/high-assurance constraints; mechanism and restore evidence | Unknown/impossible/weakened configuration plus actual mechanism/restore tests; N115-02..08 | KBN-115 | Backup operator or storage compromise | Separate failure domain residual |
|
||||||
|
| T33 | rc.3 frozen DDL could not create mission-scoped evidence/approval FKs | Tenant/evidence relational integrity | KBN-100 generated DDL from the rc.3 contract without an exact composite candidate key | rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` before both dependent FKs while retaining global and project-congruent keys | KBN-100 must execute N100-45..50: exact-key reconciliation, candidate-before-FKs, duplicate feasibility, empty/prod/N-1/rollback, and both-child foreign-workspace negatives | KBN-100 after PR/CI/#753 release | Runtime DDL remains unimplemented and must prove the frozen order | **Resolved by rc.4 + independent APPROVE; implementation evidence remains required** |
|
||||||
|
|
||||||
|
## 5. Constraint-impact matrix
|
||||||
|
|
||||||
|
| Impact ID | Required invariant | Frozen schema impact | API/transaction impact | Required evidence | Owner | Status |
|
||||||
|
| --------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| CI-01 | Hard workspace tenancy and no oracle | `workspace_id`, workspace-aware unique/FKs on all canonical rows | Server-derived workspace; uniform denial on all surfaces | N100-01..14; N110-01..09; N130-01 | KBN-100/105/110/130 | Resolved by frozen controls |
|
||||||
|
| CI-02 | Active user membership | Membership row plus unique `(workspace_id,user_id)`; active state retained | Recheck active membership in same authoritative transaction | N100-03; N110-06/07 | KBN-100/110 | Resolved; not FK-only |
|
||||||
|
| CI-03 | Service identity least privilege/revocation | Agent/session workspace, lifecycle, state, roles, capabilities | Token maps to exact agent/session; command-family allowlist; DB recheck; no admin/raw DB fallback | N105-01; N110-10..13; N210-01/02 | KBN-105/110/210 | Resolved at auth/API layer |
|
||||||
|
| CI-04 | Project-congruent hierarchy | Composite project/mission/milestone/parent/current-milestone relations | Lock/serialized parent-cycle and orphan validation | N100-06..10 | KBN-100/110 | Resolved; cycle behavior required |
|
||||||
|
| CI-05 | Health-proof authority | Internal branded proof has transaction/time/policy fields | Probe and revalidate on same PG transaction; no public field | N105-02/03; N110-14..27 | KBN-105/110 | Resolved by frozen controls |
|
||||||
|
| CI-06 | Assignment/approval identity | Exactly-one principal/proposer, exact agent/session assignment, relational approval | Reload+lock all IDs; compare version/target/state/expiry/policy/decision | N100-15..18; N210-03..19 | KBN-100/210 | Resolved by frozen controls |
|
||||||
|
| CI-07 | Monotonic bigint fencing | Durable bigint counter, exact lease/fence keys, one active lease | Atomic increment/RETURNING; decimal-string DTO; reject every stale worker command | N100-19..23; N210-20..31 | KBN-100/105/210 | Resolved by frozen controls |
|
||||||
|
| CI-08 | Proposal event chain | Both workspace-aware event FKs; event table created first | Exact submission/acceptance semantic checks in one transaction | N100-24..26; N110-28..43 | KBN-100/110 | Resolved; semantic checks not FK-only |
|
||||||
|
| CI-09 | Immutable audit/evidence retention | RESTRICT parents; INSERT/SELECT-only immutable tables | Archive/cancel normal flow; separately authorized purge | N100-27..31; N115-01; N230-02/03 | KBN-100/115/230 | Resolved by frozen controls |
|
||||||
|
| CI-10 | DB/Valkey/outbox/restart semantics | PG outbox and durable orchestration rows | Fail closed; same-key uncertainty retry; Valkey reloads PG; restart from PG | N110-44..49; N140-01/02; N230-04..07 | KBN-110/210/230 | Resolved by frozen controls |
|
||||||
|
| CI-11 | DAG/race/idempotency/version | Unique edge; self check; event idempotency; aggregate versions | Serialized recursive cycle check; payload binding; expected-version conflict | N100-32..35; N110-50..58; N200-01/02 | KBN-100/110/200 | Resolved by frozen controls |
|
||||||
|
| CI-12 | Import/cutover trust boundary | Lineage/artifact/event fields; shadow state cannot dispatch | One-way scoped importer, freeze, no direct DB/file authority, no dual writer | N300-01..08; N320-01..06 | KBN-300/320/330 | Resolved by frozen controls |
|
||||||
|
| CI-13 | Generated-file no-import | No canonical file schema/import contract | Projection-only package; static reachability check removes current parser from production Kanban paths | N120-03..07; N140-04 | KBN-120/140 | Resolved by frozen controls |
|
||||||
|
| CI-14 | Mission-scoped artifact and approval FKs | rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` and retains global/project-congruent keys | KBN-100 must emit the candidate before both exact RESTRICT FKs and preserve N-1/rollback order | N100-45..50: exact reconciliation, duplicate feasibility, empty/prod/N-1/rollback, and separate artifact/approval foreign-workspace negatives | KBN-100 after PR/CI/#753 release | **Resolved by rc.4 and independent APPROVE; future executable evidence required** |
|
||||||
|
|
||||||
|
## 6. Exact future negative-test catalog
|
||||||
|
|
||||||
|
These names are normative evidence identifiers for future slices. Equivalent test-file names are acceptable only if traceability retains these IDs and expected outcomes.
|
||||||
|
|
||||||
|
### KBN-100 — schema and migration
|
||||||
|
|
||||||
|
- **N100-01** reject every canonical child row whose `workspace_id` differs from its parent.
|
||||||
|
- **N100-02** reject foreign-workspace link, artifact, proposal target, dependency, assignment, lease, checkpoint, approval, and event relationships.
|
||||||
|
- **N100-03** reject an inactive/revoked member as accountable owner, proposer, decision actor, archive actor, or user principal in the authoritative command transaction.
|
||||||
|
- **N100-04** reject a team/project relation crossing workspaces.
|
||||||
|
- **N100-05** reject a team authorization path when the user lacks active membership in the team's workspace.
|
||||||
|
- **N100-06** reject task→mission project mismatch.
|
||||||
|
- **N100-07** reject task→milestone and project→current-milestone project mismatch.
|
||||||
|
- **N100-08** reject task→parent project mismatch and self-parent.
|
||||||
|
- **N100-09** reject indirect parent cycles under concurrent transactions.
|
||||||
|
- **N100-10** reject mission→milestone project mismatch/orphan.
|
||||||
|
- **N100-11** reject checkpoint artifact from another workspace.
|
||||||
|
- **N100-12** reject checkpoint artifact owned by another same-workspace task/mission unless an explicitly frozen evidence rule permits it.
|
||||||
|
- **N100-13** reject approval evidence from another workspace.
|
||||||
|
- **N100-14** reject same-workspace approval evidence unrelated to the approval target.
|
||||||
|
- **N100-15** reject zero/multiple assignment principals and zero/multiple proposers.
|
||||||
|
- **N100-16** reject target session without its exact target agent.
|
||||||
|
- **N100-17** reject assignment task/agent/session crossing workspaces.
|
||||||
|
- **N100-18** reject non-positive task version and expired assignment acquisition.
|
||||||
|
- **N100-19** concurrent lease insert permits one active lease and returns one winner.
|
||||||
|
- **N100-20** successive leases return strictly increasing bigint fences.
|
||||||
|
- **N100-21** reject checkpoint with another task, lease, or fence.
|
||||||
|
- **N100-22** reject duplicate/non-monotonic checkpoint sequence.
|
||||||
|
- **N100-23** reject evidence join for a mismatched checkpoint/task.
|
||||||
|
- **N100-24** proposal insert without exact submission event fails atomically.
|
||||||
|
- **N100-25** foreign/wrong-type/wrong-proposal submission event fails atomically.
|
||||||
|
- **N100-26** foreign/wrong-target/unrelated acceptance event fails atomically.
|
||||||
|
- **N100-27** application role cannot UPDATE/DELETE `task_events`.
|
||||||
|
- **N100-28** application role cannot UPDATE/DELETE checkpoints/artifacts/evidence joins.
|
||||||
|
- **N100-29** parent hard delete is RESTRICTed while audit/evidence children exist.
|
||||||
|
- **N100-30** archive does not alter canonical lifecycle status.
|
||||||
|
- **N100-31** purge without break-glass authority/evidence is denied.
|
||||||
|
- **N100-32** reject dependency self-edge and duplicate directed pair regardless of type.
|
||||||
|
- **N100-33** reject direct and indirect dependency cycles.
|
||||||
|
- **N100-34** concurrent reciprocal dependency inserts cannot both commit.
|
||||||
|
- **N100-35** readiness remains false until every blocking predecessor and completion condition passes.
|
||||||
|
- **N100-36** empty DB migration succeeds after the contract amendment.
|
||||||
|
- **N100-37** production-shape expand retains all legacy declarations.
|
||||||
|
- **N100-38** crash/resume backfill is idempotent and checksum-stable.
|
||||||
|
- **N100-39** ambiguous workspace/owner/assignee is quarantined, never guessed.
|
||||||
|
- **N100-40** no `ready`/`in_review` status is emitted to N-1 readers before switch.
|
||||||
|
- **N100-41** `mission_tasks.status` cannot remain a write source.
|
||||||
|
- **N100-42** tags/assignee/date/mission JSON/config/description/agent fields reconcile without loss.
|
||||||
|
- **N100-43** claimed fleet backlog rows are quarantined and imported rows cannot dispatch.
|
||||||
|
- **N100-44** pre-switch rollback works while post-first-mutation rollback requires freeze/reconciliation.
|
||||||
|
- **N100-45** reconcile both exact child FK column lists to the rc.4 `(workspace_id,id)` mission candidate while retaining the global `id` primary key and `(workspace_id,project_id,id)` key.
|
||||||
|
- **N100-46** empty-DB migration creates `missions_workspace_id_uidx` before `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk`.
|
||||||
|
- **N100-47** production-shape preflight finds no duplicate `(workspace_id,id)` groups, preserves global `id` uniqueness, and applies the candidate before both dependent FKs.
|
||||||
|
- **N100-48** N-1 startup/read/write remains unchanged; pre-switch rollback drops both dependents before the candidate and preserves the global/project-congruent keys.
|
||||||
|
- **N100-49** artifact insert using a valid mission ID paired with a foreign workspace fails before commit.
|
||||||
|
- **N100-50** approval-decision insert using a valid mission ID paired with a foreign workspace fails before commit.
|
||||||
|
|
||||||
|
### KBN-105/KBN-110/KBN-120/KBN-130/KBN-140 — API and P1
|
||||||
|
|
||||||
|
- **N105-01** every route has an explicit user/service command-family policy; user/admin tokens cannot call service-only Coordinator mutations.
|
||||||
|
- **N105-02** public DTO validation rejects `writeProof`, internal context, body `workspaceId`, and caller-asserted health.
|
||||||
|
- **N105-03** fixture exhaustiveness prevents 503, 502/504/timeout, and 409 cross-mapping.
|
||||||
|
- **N105-04** approval DTO accepts an ID and decision command only, never approval proof-by-value.
|
||||||
|
- **N105-05** all fence fields accept/emit decimal strings and reject JSON numbers.
|
||||||
|
- **N110-01** listing with a foreign `workspaceId` or foreign filter ID follows the frozen no-oracle denial and returns no rows/counts/cursors.
|
||||||
|
- **N110-02** get by foreign or nonexistent aggregate ID has the same frozen denial shape and no foreign metadata.
|
||||||
|
- **N110-03** create/update/archive with a foreign owner, parent, project, mission, milestone, tag, or target ID is denied before mutation.
|
||||||
|
- **N110-04** dependency/proposal commands with foreign target IDs are denied with unchanged state/event/outbox counts.
|
||||||
|
- **N110-05** REST, MCP, WebSocket, and internal Coordinator paths produce equivalent no-oracle behavior for the same foreign ID.
|
||||||
|
- **N110-06** a revoked/inactive owner is denied even with a still-valid Better Auth session.
|
||||||
|
- **N110-07** stale membership/team cache cannot authorize a proposer, decision actor, archive actor, or principal after revocation.
|
||||||
|
- **N110-08** a team ID from another workspace cannot authorize or own the command target.
|
||||||
|
- **N110-09** same-workspace but wrong-project mission/milestone/parent IDs are denied inside the transaction.
|
||||||
|
- **N110-10** an expired service token is denied before repository access.
|
||||||
|
- **N110-11** an audience- or workspace-mismatched service token is denied without an existence oracle.
|
||||||
|
- **N110-12** an over-scoped service token cannot call a command family absent from its role/capability allowlist.
|
||||||
|
- **N110-13** disabled agent or ended session revokes service-token command authority immediately on PostgreSQL recheck.
|
||||||
|
- **N110-14** contradictory public health state/boolean combinations fail validation.
|
||||||
|
- **N110-15** Valkey-only liveness cannot mint or substitute a PostgreSQL write proof.
|
||||||
|
- **N110-16** caller-forged public `healthy` cannot enter internal mutation context.
|
||||||
|
- **N110-17** public REST/MCP/CLI bodies containing health/proof fields are rejected.
|
||||||
|
- **N110-18** an expired internal proof produces no state/event/outbox write.
|
||||||
|
- **N110-19** a future-dated or not-yet-valid proof produces no write.
|
||||||
|
- **N110-20** a policy-revision-mismatched proof produces no write.
|
||||||
|
- **N110-21** a proof minted on another transaction/connection produces no write.
|
||||||
|
- **N110-22** a proof that expires before the final pre-mutation check produces no write.
|
||||||
|
- **N110-23** deliberate read-only/write-unavailable denial maps only to authoritative 503/not-applied/non-retryable.
|
||||||
|
- **N110-24** timeout before commit maps to transport-unknown and permits only same-key retry.
|
||||||
|
- **N110-25** timeout after commit maps to transport-unknown and same-key retry returns the committed canonical result once.
|
||||||
|
- **N110-26** expected-version mismatch maps only to 409/not-applied/non-retryable.
|
||||||
|
- **N110-27** recovery replay with a changed idempotency key cannot masquerade as the original uncertain request.
|
||||||
|
- **N110-28** pending proposal cannot alter target fields/status/rank/version.
|
||||||
|
- **N110-29** rejected proposal cannot affect readiness, dependencies, or gates.
|
||||||
|
- **N110-30** pending/rejected proposal cannot create an assignment or lease.
|
||||||
|
- **N110-31** direct proposal-row state manipulation cannot bypass normal command execution.
|
||||||
|
- **N110-32** proposal submission without a submission event rolls back fully.
|
||||||
|
- **N110-33** foreign-workspace submission event rolls back fully.
|
||||||
|
- **N110-34** wrong aggregate/event type submission event rolls back fully.
|
||||||
|
- **N110-35** same-workspace event for another proposal rolls back fully.
|
||||||
|
- **N110-36** submission event with wrong previous/new version semantics rolls back fully.
|
||||||
|
- **N110-37** foreign-workspace acceptance event rolls back proposal, target, event, and outbox.
|
||||||
|
- **N110-38** same-workspace event for another target aggregate rolls back acceptance.
|
||||||
|
- **N110-39** event from an unrelated normal command rolls back acceptance.
|
||||||
|
- **N110-40** event caused by a different submission event rolls back acceptance.
|
||||||
|
- **N110-41** event whose payload lacks or changes `changeProposalId` rolls back acceptance.
|
||||||
|
- **N110-42** event for another proposal with the same target/command rolls back acceptance.
|
||||||
|
- **N110-43** missing accepted-command event after target handling rolls back the entire transaction.
|
||||||
|
- **N110-44** read-only-degraded denial changes no DB row/outbox/file/Valkey/provider state.
|
||||||
|
- **N110-45** write-unavailable denial changes no DB row/outbox/file/Valkey/provider state.
|
||||||
|
- **N110-46** PostgreSQL disconnect cannot redirect a command to any fallback writer.
|
||||||
|
- **N110-47** commit uncertainty remains `unknown` and never becomes a fabricated 503/not-applied result.
|
||||||
|
- **N110-48** same-key replay after recovery returns one canonical result with no duplicate event/outbox row.
|
||||||
|
- **N110-49** Valkey publication failure leaves committed PG outbox pending and replayable.
|
||||||
|
- **N110-50** two same-version updates produce one winner and one visible 409 loser.
|
||||||
|
- **N110-51** identical duplicate key+payload returns the prior immutable result without another event/outbox row.
|
||||||
|
- **N110-52** same key with payload/command drift is rejected as an idempotency conflict.
|
||||||
|
- **N110-53** the same key in another workspace cannot reveal or reuse the first workspace's result.
|
||||||
|
- **N110-54** stale reconnect/update cannot silently overwrite a newer aggregate revision.
|
||||||
|
- **N110-55** failure after state write but before semantic event rolls back state.
|
||||||
|
- **N110-56** failure after semantic event but before outbox rolls back state and event.
|
||||||
|
- **N110-57** failure after outbox insert but before commit rolls back state, event, and outbox.
|
||||||
|
- **N110-58** success commits matching aggregate/event/outbox revisions and correlation/causation.
|
||||||
|
- **N120-01** CLI never retries an authoritative 503 deliberate denial.
|
||||||
|
- **N120-02** CLI retries only transport-unknown outcomes and preserves the exact idempotency key.
|
||||||
|
- **N120-03** generated projection header contains non-authoritative warning, workspace/project IDs, generated time, and source revision.
|
||||||
|
- **N120-04** projection revision and records match the API snapshot revision exactly.
|
||||||
|
- **N120-05** hand-tampering is overwritten or rejected by regeneration and never mutates PostgreSQL.
|
||||||
|
- **N120-06** static/runtime reachability finds no parser/import path from `TASKS.md`, `mission.json`, or another export.
|
||||||
|
- **N120-07** projection writer has no domain mutation/raw SQL/Valkey authority.
|
||||||
|
- **N130-01** UI foreign/no-access/not-found state follows the frozen no-oracle response and renders no stale foreign data.
|
||||||
|
- **N140-01** real-Gateway DB fault journey proves fail-closed no-fallback behavior.
|
||||||
|
- **N140-02** real-Gateway Valkey-loss journey proves pending outbox replay.
|
||||||
|
- **N140-03** real-Gateway concurrent update/retry journey proves version and idempotency semantics.
|
||||||
|
- **N140-04** generated-file tamper journey proves projection parity and no import.
|
||||||
|
|
||||||
|
### KBN-115/KBN-200/KBN-210/KBN-230 — recovery and coordination
|
||||||
|
|
||||||
|
- **N115-01** retention purge without current break-glass authority, reason, immutable evidence, or bounded scope is denied and audited.
|
||||||
|
- **N115-02** recovery posture with an unknown top-level or storage field is rejected.
|
||||||
|
- **N115-03** PITR retention without WAL archival is rejected.
|
||||||
|
- **N115-04** WAL archival with zero PITR retention is rejected.
|
||||||
|
- **N115-05** claimed RPO better than the configured backup/WAL mechanism is rejected.
|
||||||
|
- **N115-06** unencrypted, optional, or same-failure-domain storage is rejected.
|
||||||
|
- **N115-07** weakened high-assurance values are rejected.
|
||||||
|
- **N115-08** shape-only validation cannot pass without normative mechanism and restore evidence.
|
||||||
|
- **N200-01** cyclic/incomplete dependency snapshots never become eligible.
|
||||||
|
- **N200-02** identical immutable snapshot+policy+time returns identical ordering and explanation with no I/O/model import.
|
||||||
|
- **N210-01** disabled agent cannot claim, ack, heartbeat, checkpoint, or submit review.
|
||||||
|
- **N210-02** ended/offline/mismatched session cannot claim, ack, heartbeat, checkpoint, or submit review.
|
||||||
|
- **N210-03** foreign-workspace task is rejected after lock/reload without an oracle.
|
||||||
|
- **N210-04** stale task version is rejected before fence increment.
|
||||||
|
- **N210-05** assignment target agent mismatch is rejected.
|
||||||
|
- **N210-06** target session mismatch is rejected.
|
||||||
|
- **N210-07** expired assignment is rejected.
|
||||||
|
- **N210-08** assignment in rejected/released/expired/superseded/leased-invalid state is rejected.
|
||||||
|
- **N210-09** missing approval is rejected.
|
||||||
|
- **N210-10** rejected/escalated/requested approval is rejected as approval authority.
|
||||||
|
- **N210-11** stale policy-revision approval is rejected.
|
||||||
|
- **N210-12** foreign-workspace approval is rejected without an oracle.
|
||||||
|
- **N210-13** approval for another assignment is rejected.
|
||||||
|
- **N210-14** author self-approval/review is rejected when independence is required.
|
||||||
|
- **N210-15** foreign-workspace artifact evidence is rejected.
|
||||||
|
- **N210-16** same-workspace artifact unrelated to the assignment/task/gate is rejected.
|
||||||
|
- **N210-17** concurrent policy revocation versus acquire cannot produce a lease under the revoked revision.
|
||||||
|
- **N210-18** concurrent assignment expiry versus acquire cannot produce a lease after expiry.
|
||||||
|
- **N210-19** concurrent session end versus acquire cannot produce a lease for the ended session.
|
||||||
|
- **N210-20** lower fencing token is rejected without writes.
|
||||||
|
- **N210-21** token from an older lease is rejected without writes.
|
||||||
|
- **N210-22** token paired with another task is rejected without writes.
|
||||||
|
- **N210-23** token paired with another session is rejected without writes.
|
||||||
|
- **N210-24** token on an expired/revoked/released lease is rejected without writes.
|
||||||
|
- **N210-25** fences above JavaScript safe integer round-trip exactly as decimal strings.
|
||||||
|
- **N210-26** lease task does not match assignment task and is rejected.
|
||||||
|
- **N210-27** lease agent/session does not match assignment target and is rejected.
|
||||||
|
- **N210-28** checkpoint task does not match lease task and is rejected.
|
||||||
|
- **N210-29** checkpoint fence does not match exact lease fence and is rejected.
|
||||||
|
- **N210-30** checkpoint sequence duplicate/regression is rejected.
|
||||||
|
- **N210-31** checkpoint artifact does not match workspace/task/evidence semantics and is rejected.
|
||||||
|
- **N210-32** restart after assignment persistence reconstructs the pending assignment.
|
||||||
|
- **N210-33** restart after lease commit reconstructs exact active lease and fence.
|
||||||
|
- **N210-34** restart after checkpoint commit reconstructs checkpoint/recovery state.
|
||||||
|
- **N210-35** restart during expiry/retry/quarantine reconstructs durable disposition and eligibility.
|
||||||
|
- **N210-36** restart with pending outbox reconstructs publication work without Valkey/files.
|
||||||
|
- **N230-01** author=self-review and missing mandatory SecReview cannot certify or complete.
|
||||||
|
- **N230-02** normal application role cannot execute retention purge.
|
||||||
|
- **N230-03** break-glass purge cannot delete or alter its own authorization/evidence chain.
|
||||||
|
- **N230-04** Valkey down leaves canonical work in PostgreSQL/outbox.
|
||||||
|
- **N230-05** duplicate wake produces one logical effect after PostgreSQL reload/idempotency.
|
||||||
|
- **N230-06** stale wake cannot revive an expired/revoked assignment or lease.
|
||||||
|
- **N230-07** restart with no Valkey/files reconstructs leases/retry/quarantine/outbox exactly.
|
||||||
|
|
||||||
|
### KBN-300/KBN-320/KBN-330/KBN-340 — migration and cutover
|
||||||
|
|
||||||
|
- **N300-01** source record targeting another workspace is denied/quarantined without an oracle.
|
||||||
|
- **N300-02** malformed source record is rejected with attributable reject evidence.
|
||||||
|
- **N300-03** duplicate source system/key/batch replay is idempotent.
|
||||||
|
- **N300-04** source snapshot/checksum drift aborts apply/verify.
|
||||||
|
- **N300-05** partial import resumes from durable lineage without duplicating state/events.
|
||||||
|
- **N300-06** imported shadow record cannot become ready, assigned, or leased automatically.
|
||||||
|
- **N300-07** missing source key/file/checksum/batch lineage prevents apply/sign-off.
|
||||||
|
- **N300-08** importer cannot use direct DB, generated file, Valkey, or provider issue as canonical write authority.
|
||||||
|
- **N320-01** cutover without a verified write freeze fails safe.
|
||||||
|
- **N320-02** active legacy writer process or credential blocks cutover.
|
||||||
|
- **N320-03** reverse and forward synchronization cannot run concurrently.
|
||||||
|
- **N320-04** failed final delta/reconciliation blocks client switch.
|
||||||
|
- **N320-05** rollback before first canonical DB mutation may switch authority back only after freeze assertion.
|
||||||
|
- **N320-06** rollback after first canonical mutation requires freeze, DB-delta export/reconciliation, and owner decision.
|
||||||
|
- **N330-01** rehearsal cannot sign off while counts/checksums/exceptions/writer inventory differ.
|
||||||
|
- **N340-01** cutover cannot proceed without owner authorization, terminal evidence, scoped identities, and zero active legacy writers.
|
||||||
|
|
||||||
|
## 7. Requirements traceability
|
||||||
|
|
||||||
|
| Requirement | Threats/impacts | Planned evidence |
|
||||||
|
| ---------------- | ---------------------------- | --------------------------------------------------------------- |
|
||||||
|
| REQ-SOT-001 | T16, T21, T22, T27, T29, T30 | N110-28..31, N110-44..49, N110-55..58, N120-03..07, N320-01..06 |
|
||||||
|
| REQ-SOT-002 | T07, T08, T09, T21 | N105-02/03, N110-14..27, N110-44..48 |
|
||||||
|
| REQ-SOT-003 | T30 | N120-03..07, N140-04 |
|
||||||
|
| REQ-SOT-004 | T16..18 | N100-24..26, N110-28..43 |
|
||||||
|
| REQ-TEN-001 | T01..05, T15, T33 | N100-01..14, N100-45..50, N110-01..09, N210-15/16 |
|
||||||
|
| REQ-ID-001 | T02, T03, T06, T10..12 | N105-01, N110-06..13, N210-01..19 |
|
||||||
|
| REQ-PLAN-001 | T04, T25 | N100-06..10 |
|
||||||
|
| REQ-TASK-001 | T13, T26, T31 | N100-20, N100-37..42, N110-50..54 |
|
||||||
|
| REQ-TASK-002 | T16, T24 | N110-28..31, N100-35, N200-01 |
|
||||||
|
| REQ-DEP-001 | T24 | N100-32..35, N200-01 |
|
||||||
|
| REQ-ASN-001 | T10..12 | N100-15..18, N210-03..19 |
|
||||||
|
| REQ-AUD-001 | T17..20, T22, T27 | N100-24..31, N110-32..43, N110-49, N110-55..58 |
|
||||||
|
| REQ-API-001 | T01, T06..18, T26 | N105-01..05 plus KBN-110 catalog |
|
||||||
|
| REQ-UI-002/003 | T01, T15, T26 | N130-01 and real-Gateway KBN-140 journeys |
|
||||||
|
| REQ-COORD-001 | T22..24 | N200-01/02, N210-32..36 |
|
||||||
|
| REQ-COORD-002 | T10..12, T16 | N210-03..19, N110-28..31 |
|
||||||
|
| REQ-COORD-003 | T13..15, T23 | N100-19..23, N210-20..36 |
|
||||||
|
| REQ-COORD-004 | T23, T26 | N210-32..36, N230-07 |
|
||||||
|
| REQ-GATE-001/002 | T11, T19, T20 | N210-09..14, N230-01..03 |
|
||||||
|
| REQ-REC-001 | T20, T32 | N115-01..08 |
|
||||||
|
| REQ-MIG-001/002 | T28, T29, T31 | N100-37..44, N300-01..08, N320-01..06, N330-01, N340-01 |
|
||||||
|
|
||||||
|
REQ-UI-001 and REQ-UI-004 are downstream functional/accessibility requirements rather than schema-threat controls; they remain owned by KBN-130/KBN-140. Their security-relevant tenancy, conflict, and stale-reconnect portions are covered above.
|
||||||
|
|
||||||
|
## 8. Issue #753 acceptance mapping
|
||||||
|
|
||||||
|
| Issue requirement/criterion | Evidence in this document | Result |
|
||||||
|
| --------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
|
||||||
|
| Cross-workspace owners, principals, evidence, project hierarchy | T01–T05, T15, T25; CI-01–04 | Mapped |
|
||||||
|
| Active membership and service-token boundaries | Authorization matrix; T02, T03, T06; CI-02/03 | Mapped |
|
||||||
|
| Stale/forged health and transaction-local proof | T07–T09, T21; CI-05 | Mapped |
|
||||||
|
| Assignment/approval forgery and monotonic fencing | T10–T15; CI-06/07 | Mapped |
|
||||||
|
| Change-proposal abuse and event binding | T16–T18; CI-08 | Mapped |
|
||||||
|
| Immutable audit and break-glass | T19/T20; CI-09 | Mapped |
|
||||||
|
| PostgreSQL/Valkey failures | T21–T23; CI-10 | Mapped |
|
||||||
|
| Dependency/idempotency/version races | T24–T27; CI-11 | Mapped |
|
||||||
|
| Import/cutover and generated-file boundary | T28–T31; CI-12/13 | Mapped |
|
||||||
|
| Every schema/API/test impact explicit | Constraint matrix and negative-test catalog | Mapped |
|
||||||
|
| No unresolved schema impact | CI-14; rc.4 resolved-impact record | **PASS — none unresolved** |
|
||||||
|
| Independent SecReview | Homelab non-author exact commit/tree/content review | **PASS / APPROVE** |
|
||||||
|
| PR merge, terminal-green main CI, and #753 closure | Orchestrator-owned post-worker gates | Pending; KBN-100 remains held until completion |
|
||||||
|
|
||||||
|
## 9. UNRESOLVED SCHEMA IMPACTS
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
### Resolved-impact record — KBN010-SI-001
|
||||||
|
|
||||||
|
- **Historical detection:** rc.3 lacked an exact `(workspace_id,id)` candidate key for the artifact and approval-decision mission FKs. This document's original BLOCKED verdict was correct and remains preserved in §1 and T33.
|
||||||
|
- **Resolution:** rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` before both exact dependent FKs while retaining the global primary key and project-congruent key.
|
||||||
|
- **Reviewed object:** commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`.
|
||||||
|
- **Corroborating identities:** full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`; stable patch-id `058cf98026fcd1043703c866aee047c8bb144740`.
|
||||||
|
- **Independent verdict:** Homelab non-author schema/security review **APPROVE**. It confirmed PostgreSQL candidate/FK validity, unchanged tenant and polymorphic exactly-one-target safety, RESTRICT/no-cascade semantics, N-1/rollback validity, and no shared table/index/FK/identity/fence authority collision with #757.
|
||||||
|
- **Digest interpretation:** a command-rendered patch digest varied with rendering command/options and is non-authoritative. Git commit + tree + exact file content are canonical; stable full-index SHA-256 and stable patch-id corroborate that identity.
|
||||||
|
- **Residual implementation obligations:** KBN-100 must create the candidate before both dependent FKs; prove production-shape duplicate feasibility without weakening global uniqueness; pass empty/prod/N-1/rollback tests; reconcile both exact FK targets; and separately reject foreign-workspace mission references for artifacts and approval decisions (N100-45..50).
|
||||||
|
- **Implementation status:** no runtime schema, migration, API, or deployment implementation is claimed by this gate disposition.
|
||||||
|
|
||||||
|
## 10. Residual risk and handoff
|
||||||
|
|
||||||
|
- Active membership, polymorphic targets, same-task evidence semantics, parent/DAG cycle checks, token scope, and no-oracle behavior depend on authoritative transaction code and must not be treated as FK-only guarantees.
|
||||||
|
- DB superuser and break-glass compromise cannot be eliminated by application constraints; separation of duties, immutable external backup/audit evidence, drills, and monitoring remain required.
|
||||||
|
- PostgreSQL unavailability intentionally sacrifices writes for integrity. Transport-unknown outcomes remain safe only when clients preserve the exact idempotency key.
|
||||||
|
- Imported ambiguous records remain quarantined until owner sign-off; no automated mapping may convert ambiguity into authority.
|
||||||
|
- SI-001 is resolved at frozen contract/design-review level only. KBN-100 still owes N100-45..50 executable migration evidence.
|
||||||
|
|
||||||
|
**Handoff status:** KBN-010 **PASS / GO** at rc.4. KBN-100 remains held until this PR squash-merges, terminal-green CI completes on `main`, and issue #753 closes; the orchestrator owns those remaining gates.
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
# Native Kanban/SOT — Remediated Shared Contract v1
|
# Native Kanban/SOT — Remediated Shared Contract v1
|
||||||
|
|
||||||
**Status:** INDEPENDENT REVIEW GO; freezes as v1 when issue #751 canon merges to `main`
|
**Status:** CONTROL-PLANE SI-001 AMENDMENT AUTHORIZED; prior KCR-001–016 independent-review GO retained; rc.4 requires independent schema/SecReview before KBN-100
|
||||||
**Version:** 1.0.0-rc.3
|
**Version:** 1.0.0-rc.4
|
||||||
**Date:** 2026-07-14
|
**Date:** 2026-07-14
|
||||||
**Change authority:** Mosaic control plane/Jason only
|
**Change authority:** Mosaic control plane/Jason only
|
||||||
|
**SI-001 amendment authority:** `web1:mosaic-100` control-plane decision under issue #753
|
||||||
|
|
||||||
|
## Amendment record
|
||||||
|
|
||||||
|
### 1.0.0-rc.4 — KBN010-SI-001
|
||||||
|
|
||||||
|
- **Choice:** add the explicitly named, non-partial unique candidate key `missions_workspace_id_uidx` on `missions(workspace_id, id)` and retain `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`.
|
||||||
|
- **Rationale:** mission `id` remains globally unique, while the composite candidate key makes the frozen tenant-safe generic mission relations valid. `artifacts` and `approval_decisions` are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would unnecessarily broaden v1 and its target semantics.
|
||||||
|
- **Exact effect:** `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` continue to reference the exact ordered columns `missions(workspace_id, id)` with RESTRICT deletion, now backed by a matching candidate key.
|
||||||
|
- **Non-effect:** no SOT, tenancy, project-congruence, proposal-audit, approval, fencing, immutability, no-cascade, API, or wire-version invariant changes. The `SuccessEnvelopeV1.contractVersion` remains `1.0.0`.
|
||||||
|
- **Historical evidence boundary:** `KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` intentionally remains the immutable rc.3 blocker verdict that detected SI-001; this rc.4 record and the #753 scratchpad append are the authorized disposition. Rewriting the gate verdict is outside this amendment's exclusive scope.
|
||||||
|
- **Gate:** this amendment resolves the DDL defect identified by KBN010-SI-001 but does not itself lift KBN-100; independent schema/SecReview remains required.
|
||||||
|
|
||||||
## 1. Authority
|
## 1. Authority
|
||||||
|
|
||||||
@@ -43,6 +55,7 @@ Complete declaration: `contracts/kanban-schema.v1.ts`.
|
|||||||
- Specialist roles everywhere: `planning | enhance | coder | review | security-review | pr-monitor | certifier`.
|
- Specialist roles everywhere: `planning | enhance | coder | review | security-review | pr-monitor | certifier`.
|
||||||
- Owner uses exactly-one user/team; assignment principal exactly-one user/team/agent; users require active membership; agent/session and all evidence are workspace-bound.
|
- Owner uses exactly-one user/team; assignment principal exactly-one user/team/agent; users require active membership; agent/session and all evidence are workspace-bound.
|
||||||
- Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations.
|
- Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations.
|
||||||
|
- Mission `id` remains globally unique. The additional non-partial `missions_workspace_id_uidx` candidate key on `(workspace_id, id)` exists only to support the frozen workspace-safe polymorphic artifact and approval-decision mission relations; the project-congruent `(workspace_id, project_id, id)` key remains authoritative wherever `project_id` is present.
|
||||||
- Dependency identity is workspace+predecessor+successor independent of type.
|
- Dependency identity is workspace+predecessor+successor independent of type.
|
||||||
- Approval evidence and checkpoint evidence are workspace-scoped joins to immutable artifacts, never JSON ID arrays.
|
- Approval evidence and checkpoint evidence are workspace-scoped joins to immutable artifacts, never JSON ID arrays.
|
||||||
- Proposal audit links are composite relations: `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` reference `task_events(workspace_id, id)` with RESTRICT deletion.
|
- Proposal audit links are composite relations: `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` reference `task_events(workspace_id, id)` with RESTRICT deletion.
|
||||||
@@ -76,7 +89,20 @@ Legacy columns remain declared in unified `schema.ts` for expand + full N-1/roll
|
|||||||
6. **Switch:** stop N-1 writers; Gateway sole command boundary; enable canonical statuses.
|
6. **Switch:** stop N-1 writers; Gateway sole command boundary; enable canonical statuses.
|
||||||
7. **Contract release:** later release after rollback/N-1; remove compatibility/global uniques/legacy fields.
|
7. **Contract release:** later release after rollback/N-1; remove compatibility/global uniques/legacy fields.
|
||||||
|
|
||||||
### 5.2 New audit/proposal DDL order
|
### 5.2 Mission candidate-key and dependent-FK DDL order
|
||||||
|
|
||||||
|
KBN-100 migration DDL must execute the SI-001 portion in this order:
|
||||||
|
|
||||||
|
1. expand/backfill `missions.workspace_id` and `missions.project_id` while preserving the global `missions.id` primary key and the project-congruent `missions_workspace_project_id_uidx` key;
|
||||||
|
2. prove duplicate-key feasibility on the production-shape dataset: `(workspace_id, id)` has no duplicate groups and global `id` uniqueness remains intact;
|
||||||
|
3. create the non-partial unique index `missions_workspace_id_uidx` on exact ordered columns `(workspace_id, id)`;
|
||||||
|
4. only after step 3, create/alter `artifacts` and add `artifacts_workspace_mission_fk` from `(workspace_id, mission_id)` to exact `missions(workspace_id, id)` with `ON DELETE RESTRICT`;
|
||||||
|
5. only after step 3, create/alter `approval_decisions` and add `approval_decisions_workspace_mission_fk` from `(workspace_id, mission_id)` to exact `missions(workspace_id, id)` with `ON DELETE RESTRICT`;
|
||||||
|
6. validate both constraints and prove a mission ID paired with a foreign workspace is rejected for each child.
|
||||||
|
|
||||||
|
The candidate key is intentionally redundant with globally unique `missions.id`, but PostgreSQL requires a matching unique candidate key for the exact two-column FK target. It is additive and N-1-safe. Pre-switch rollback drops the two dependent FKs/tables before dropping this candidate key, preserves the global primary key and project-congruent key, and follows the existing freeze/reconciliation rule after the first canonical mutation.
|
||||||
|
|
||||||
|
### 5.3 New audit/proposal DDL order
|
||||||
|
|
||||||
KBN-100 migration DDL must execute in this order:
|
KBN-100 migration DDL must execute in this order:
|
||||||
|
|
||||||
@@ -88,15 +114,16 @@ KBN-100 migration DDL must execute in this order:
|
|||||||
|
|
||||||
The submission transaction inserts the event first using a preallocated proposal UUID, then the proposal. Acceptance inserts the normal command event before updating the locked proposal. Neither FK is omitted or replaced by a bare UUID/index check.
|
The submission transaction inserts the event first using a preallocated proposal UUID, then the proposal. Acceptance inserts the normal command event before updating the locked proposal. Neither FK is omitted or replaced by a bare UUID/index check.
|
||||||
|
|
||||||
### 5.3 Field map
|
### 5.4 Field map
|
||||||
|
|
||||||
| Current | Expand/backfill | N-1 compatibility | Switch/contract |
|
| Current | Expand/backfill | N-1 compatibility | Switch/contract |
|
||||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |
|
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||||
| global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later |
|
| global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later |
|
||||||
| `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later |
|
| `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later |
|
||||||
| project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity |
|
| project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity |
|
||||||
| current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority |
|
| current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority |
|
||||||
| nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later |
|
| nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later |
|
||||||
|
| mission relational candidate keys | retain global `id` PK and project-congruent key; add non-partial `(workspace_id,id)` key before artifact/approval FKs | additive key is ignored safely by N-1 readers/writers | retain both composite keys; generic mission children use exact workspace+ID target |
|
||||||
| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review |
|
| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review |
|
||||||
| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority |
|
| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority |
|
||||||
| mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off |
|
| mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off |
|
||||||
@@ -114,10 +141,12 @@ The submission transaction inserts the event first using a preallocated proposal
|
|||||||
| agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory |
|
| agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory |
|
||||||
| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization |
|
| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization |
|
||||||
|
|
||||||
### 5.4 Required migration tests
|
### 5.5 Required migration tests
|
||||||
|
|
||||||
Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release.
|
Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release.
|
||||||
|
|
||||||
|
SI-001 adds frozen future executable evidence: empty and production-shape migrations create `missions_workspace_id_uidx` before either dependent FK; duplicate-key feasibility preflight returns no `(workspace_id,id)` duplicate groups without weakening global `id` uniqueness; N-1 startup/read/write behavior is unchanged; pre-switch rollback removes dependents before the candidate key; both exact FK column lists reconcile to the candidate key; and foreign-workspace mission references fail for both artifacts and approval decisions. TDD is not applicable to this design-only amendment; KBN-100 must implement these negative migration tests before runtime schema release.
|
||||||
|
|
||||||
Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation.
|
Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation.
|
||||||
|
|
||||||
## 6. Ownership and Coordinator split
|
## 6. Ownership and Coordinator split
|
||||||
@@ -216,4 +245,10 @@ KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recove
|
|||||||
|
|
||||||
Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
|
Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
|
||||||
|
|
||||||
The build hold remains active until independent re-review reports GO for KCR-001–016. Mos alone releases waves and serializes integration roots.
|
### 9.1 SI-001 amendment gate and #757 boundary
|
||||||
|
|
||||||
|
- KBN-100 must provide the §5.2 candidate-key ordering, duplicate-feasibility, exact-FK reconciliation, empty/prod/N-1/rollback, and two-child foreign-workspace evidence before SI-001 can be certified closed.
|
||||||
|
- All prior KCR-001–016 decisions and fixed SOT/tenant/authority, proposal-audit, approval, task-fencing, immutability, and no-cascade invariants remain unchanged.
|
||||||
|
- Read-only PR #757 cross-check: its logical-agent connector lease/CAS fencing uses separate runtime tables/contracts and `lease_epoch`; rc.4 changes only the frozen `missions` candidate key. There is no shared table, index, FK, identity, fence, or authority semantic to consume or reconcile, and #757 remains owned by its existing lane.
|
||||||
|
|
||||||
|
The build hold remains active until independent re-review reports GO for KCR-001–016 and the rc.4 SI-001 amendment. Mos alone releases waves and serializes integration roots.
|
||||||
|
|||||||
@@ -70,19 +70,21 @@ No consumer implementation begins before KBN-105. No schema work begins before K
|
|||||||
|
|
||||||
### KBN-000 — Remediate and publish canon
|
### KBN-000 — Remediate and publish canon
|
||||||
|
|
||||||
- **Owner:** Mos / publication control plane.
|
- **Status:** COMPLETE — PR #752 squash-merged as `49e8a54`; issue #751 closed; post-merge pipeline #1798 terminal success.
|
||||||
- **Mode:** SERIAL; publication gate in progress.
|
- **Owner:** Mosaic publication control plane.
|
||||||
|
- **Mode:** SERIAL; completed.
|
||||||
- **IN:** Resolve KCR-001–016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review.
|
- **IN:** Resolve KCR-001–016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review.
|
||||||
- **OUT:** Feature implementation.
|
- **OUT:** Feature implementation.
|
||||||
- **Depends on:** none.
|
- **Depends on:** none.
|
||||||
- **Contract surfaces:** all canon.
|
- **Contract surfaces:** all canon.
|
||||||
- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO.
|
- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO; Ultron GO; terminal-green CI.
|
||||||
|
|
||||||
### KBN-010 — Threat, authorization, and constraint-impact gate
|
### KBN-010 — Threat, authorization, and constraint-impact gate
|
||||||
|
|
||||||
- **Owner:** coder3; independent `secrev`.
|
- **Status:** IN PROGRESS — issue [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753).
|
||||||
|
- **Owner:** `kbn-coder3`; independent `secrev`.
|
||||||
- **Mode:** SERIAL prerequisite of KBN-100.
|
- **Mode:** SERIAL prerequisite of KBN-100.
|
||||||
- **Exclusive files:** Mos-selected threat/auth docs only.
|
- **Exclusive files:** `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and task scratchpad only.
|
||||||
- **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage.
|
- **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage.
|
||||||
- **OUT:** Runtime/schema edits.
|
- **OUT:** Runtime/schema edits.
|
||||||
- **Depends on:** KBN-000 independent re-review GO.
|
- **Depends on:** KBN-000 independent re-review GO.
|
||||||
|
|||||||
@@ -485,6 +485,7 @@ export const missionsV1 = pgTable(
|
|||||||
foreignColumns: [projectsV1.workspaceId, projectsV1.id],
|
foreignColumns: [projectsV1.workspaceId, projectsV1.id],
|
||||||
}).onDelete('restrict'),
|
}).onDelete('restrict'),
|
||||||
uniqueIndex('missions_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id),
|
uniqueIndex('missions_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id),
|
||||||
|
uniqueIndex('missions_workspace_id_uidx').on(t.workspaceId, t.id),
|
||||||
index('missions_workspace_project_status_idx').on(
|
index('missions_workspace_project_status_idx').on(
|
||||||
t.workspaceId,
|
t.workspaceId,
|
||||||
t.projectId,
|
t.projectId,
|
||||||
|
|||||||
101
docs/scratchpads/753-kbn010-threat-gate.md
Normal file
101
docs/scratchpads/753-kbn010-threat-gate.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Issue #753 — KBN-010 threat, authorization, and constraint-impact gate
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Complete the mandatory threat/auth/constraint-impact analysis that gates KBN-100 schema implementation.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- In: threat matrix, frozen-control mapping, schema/API/test impact inventory, security evidence plan.
|
||||||
|
- Out: runtime, schema, migration, API, dependency, CI, deployment, and secret changes.
|
||||||
|
- Canonical requirements: `docs/requirements/native-kanban-sot.md`.
|
||||||
|
- Frozen contract: `docs/native-kanban-sot/SHARED-CONTRACT.md` and `contracts/*.v1.ts`.
|
||||||
|
- Tracking: Mosaic Stack issue #753.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Independently inspect current-main implementation and frozen canon.
|
||||||
|
2. Enumerate tenant, identity, health-proof, approval, fencing, proposal, audit, token, and outage threats.
|
||||||
|
3. Map every threat to required constraints, command behavior, negative tests, and owning future slice.
|
||||||
|
4. Surface any unresolved schema impact as a blocker; do not silently amend the frozen contract.
|
||||||
|
5. Run documentation/static validation and submit for independent SecReview.
|
||||||
|
|
||||||
|
## Execution log
|
||||||
|
|
||||||
|
- 2026-07-14: KBN-000 completed through PR #752 and post-merge pipeline #1798.
|
||||||
|
- 2026-07-14: KBN-010 issue #753 created; task marked in progress; fresh GPT worker pending dispatch.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
Pending worker validation, independent SecReview, Ultron gate, PR merge, post-merge CI, and issue closure.
|
||||||
|
|
||||||
|
## 2026-07-14 worker analysis checkpoint
|
||||||
|
|
||||||
|
- Inspected issue #753, canonical requirements, all frozen v1 contracts, and actual `origin/main` at `49e8a54` across DB schema, Better Auth scope, project/task/mission/team repositories/controllers, fleet backlog, and `TASKS.md` parsing/writing.
|
||||||
|
- Confirmed the worker branch has no source/runtime/schema delta from `origin/main`; orchestrator-owned `.mosaic` state remains dirty and untouched.
|
||||||
|
- Authored the threat, authorization, constraint-impact, negative-test, and requirements-traceability analysis in `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md`.
|
||||||
|
- Gate decision: **BLOCKED** by `KBN010-SI-001`. `missionsV1` lacks a unique candidate key on `(workspace_id, id)`, while `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` both reference that exact pair. PostgreSQL cannot create the frozen composite foreign keys as declared.
|
||||||
|
- Decision: do not select or apply a schema fix. Contract authority must version either a `(workspace_id, id)` mission candidate key or project-congruent child keys, then obtain independent re-review before KBN-100.
|
||||||
|
- Additional risks are controlled by frozen transaction/API behavior but require the exact future negative tests cataloged in the deliverable, especially active membership, service-token revocation, same-workspace semantic evidence checks, no-oracle behavior, and serialized parent/DAG cycle checks.
|
||||||
|
- OpenBrain startup recall was attempted but unavailable because `/home/hermes/.config/mosaic/credentials.json` is absent; no project state was written to an alternate memory silo.
|
||||||
|
- TDD: not applicable; this slice changes documentation/test-plan analysis only and implements no runtime behavior.
|
||||||
|
|
||||||
|
## Validation log
|
||||||
|
|
||||||
|
- `pnpm exec prettier --check docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md docs/scratchpads/753-kbn010-threat-gate.md` — PASS.
|
||||||
|
- Changed-doc link validator — PASS (`relative_links=0`, one external issue link); `curl -fsSIL https://git.mosaicstack.dev/mosaicstack/stack/issues/753` — PASS.
|
||||||
|
- The first inline link-validator invocation had a Python f-string syntax error; corrected once and rerun successfully without changing the deliverable.
|
||||||
|
- `pnpm format:check` — PASS.
|
||||||
|
- `pnpm lint` — PASS (23 tasks successful).
|
||||||
|
- `pnpm typecheck` — PASS (42 tasks successful).
|
||||||
|
- `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json --noEmit` — PASS.
|
||||||
|
- Scoped diff review — PASS: authored delta is limited to the exclusive deliverable and this scratchpad; no runtime/schema/config/dependency/CI/deployment file is changed, and `docs/native-kanban-sot/TASKS.md` has no worker worktree delta from tracking commit `9b55de0`.
|
||||||
|
- Independent SecReview remains pending and cannot return PASS until contract authority resolves `KBN010-SI-001`.
|
||||||
|
- Final formatting/diff/test-ID completeness review — PASS (all catalog prefixes contiguous with no duplicate IDs).
|
||||||
|
- Remaining: scoped commit, queue guard, and push.
|
||||||
|
|
||||||
|
## 2026-07-14 KBN010-SI-001 contract-authority amendment
|
||||||
|
|
||||||
|
- Authority: `web1:mosaic-100` directed the minimal rc.4 amendment under issue #753: add a non-partial unique candidate key on `missions(workspace_id, id)` while retaining global `missions.id` uniqueness and the project-congruent `(workspace_id, project_id, id)` key.
|
||||||
|
- Rationale: artifacts and approval decisions are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would broaden frozen v1 semantics without improving tenant safety.
|
||||||
|
- Plan: amend only the frozen schema contract and shared contract, freeze exact DDL ordering and future migration negatives, run scoped/full validation and independent review, then commit and push without opening/merging a PR or closing #753.
|
||||||
|
- TDD: not applicable because this is a design-contract amendment with no runtime schema or migration implementation; rc.4 freezes future executable empty/prod/N-1/rollback/foreign-workspace and duplicate-key-feasibility tests.
|
||||||
|
- Budget: no explicit cap supplied; use a 20K-equivalent soft working cap and one bounded worker lane.
|
||||||
|
- Read-only #757 boundary: PR #757 adds separate logical-agent connector lease/CAS fencing (`logical_agent_connector_leases.lease_epoch`) in runtime schema and connector contracts. SI-001 changes only the frozen mission candidate key; it does not consume, alter, or reinterpret connector fencing, task fencing, lease authority, or #757 ownership.
|
||||||
|
|
||||||
|
### Amendment verification evidence
|
||||||
|
|
||||||
|
- Frozen schema: added exactly one non-partial `missions_workspace_id_uidx` on `(workspace_id, id)`; retained the global `id` primary key and `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`.
|
||||||
|
- FK reconciliation: targeted static checks prove the candidate key precedes both `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk`; each continues to reference exact ordered columns `(workspace_id, mission_id)` → `missions(workspace_id, id)` with RESTRICT deletion.
|
||||||
|
- Shared contract: candidate version is `1.0.0-rc.4`; authority, rationale, exact effect/non-effect, candidate-before-FK DDL order, duplicate feasibility, empty/prod/N-1/rollback/foreign-workspace future tests, and unchanged KCR/SOT/tenant/proposal/fencing/no-cascade invariants are explicit.
|
||||||
|
- Changed-file Prettier, contract ESLint, `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json`, and targeted SI-001 static checks — PASS.
|
||||||
|
- Full `pnpm format:check && pnpm lint && pnpm typecheck` — PASS (23 lint tasks and 42 typecheck/build tasks).
|
||||||
|
- Independent Codex security review — PASS, zero critical/high/medium/low findings; tenant isolation is preserved.
|
||||||
|
- Independent Codex code review confirmed the candidate key repairs both FK targets and reported no blocker on the authorized delta. Its initial request to rewrite the historical KBN-010 verdict conflicts with the exclusive scope and was resolved by documenting the immutable-evidence boundary in rc.4; its remaining finding concerns pre-existing live `.mosaic` session files, which are untouched and excluded from this commit.
|
||||||
|
- Scoped diff: only the two frozen contract files and this append-only scratchpad amendment are staged for delivery; no runtime schema, migration, task plan, gate verdict, provider artifact, or #757-owned file is included.
|
||||||
|
|
||||||
|
## 2026-07-14 final rc.4 KBN-010 disposition
|
||||||
|
|
||||||
|
- Control-plane direction authorized final disposition edits only to `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and this append-only scratchpad; contract author `web1:kbn-contract` remained idle and undisturbed.
|
||||||
|
- Exact reviewed contract object: commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`.
|
||||||
|
- Corroborating review identities: full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`; stable patch-id `058cf98026fcd1043703c866aee047c8bb144740`. A command-rendered patch digest varied by Git rendering command/options and is non-authoritative; commit+tree+exact file content are canonical.
|
||||||
|
- Independent Homelab non-author schema/security review verdict: **APPROVE**. It confirmed the rc.4 `missions_workspace_id_uidx(workspace_id,id)` repairs both dependent FKs while retaining the global PK and project-congruent key; tenant, polymorphic exactly-one-target, RESTRICT/no-cascade, N-1/rollback semantics remain valid.
|
||||||
|
- Read-only #757 cross-check: no shared table, index, FK, identity, fence, or authority collision with connector lease/CAS fencing.
|
||||||
|
- Final KBN-010 gate decision: **PASS / GO** at rc.4 with `UNRESOLVED SCHEMA IMPACTS` equal to exact `none`. Historical SI-001 detection remains in the gate document as evidence that rc.3 was invalid.
|
||||||
|
- No runtime schema/migration/API/config/dependency/CI/deployment implementation is claimed. KBN-100 must still implement candidate-before-dependent-FK ordering, duplicate feasibility, empty/prod/N-1/rollback evidence, exact FK reconciliation, and separate artifact/approval foreign-workspace negatives (N100-45..50).
|
||||||
|
- TDD remains not applicable because this continuation changes only documentation/evidence disposition and no runtime behavior.
|
||||||
|
- Remaining orchestrator-owned sequence: worker validation/commit/push → PR open/update → Ultron review → squash merge → terminal-green post-main CI → close #753 → release KBN-100. KBN-100 is not released earlier.
|
||||||
|
|
||||||
|
### Final worker validation
|
||||||
|
|
||||||
|
- Changed-doc Prettier and link checks — PASS; issue #753 external link returned successfully.
|
||||||
|
- Static future-test catalog check — PASS: all prefixes are individually enumerated, contiguous, and duplicate-free; N100 now spans N100-01..50.
|
||||||
|
- rc.4 disposition assertions — PASS: gate status PASS/GO, frozen target rc.4, exact `none` unresolved section, independent APPROVE, review identities, and N100-50 are present.
|
||||||
|
- Review identity reproduction — PASS: commit tree `7ebab8fa530a7180036928cea9527f808548aa14`, full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`, and stable patch-id `058cf98026fcd1043703c866aee047c8bb144740` match.
|
||||||
|
- rc.4 frozen-contract static check — PASS: the candidate key is unique in the declaration and precedes both dependent FKs; frozen contract files remain byte-identical to commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`.
|
||||||
|
- `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json --noEmit` — PASS.
|
||||||
|
- `pnpm format:check` — PASS.
|
||||||
|
- `pnpm lint` — PASS (23 tasks successful).
|
||||||
|
- `pnpm typecheck` — PASS (42 tasks successful).
|
||||||
|
- Scoped diff — PASS: only the gate document and this scratchpad are authored changes; contracts, TASKS, requirements, runtime/schema/migration/config/dependency/CI/deployment and #757-owned files are unchanged. Live `.mosaic` session state remains untouched and excluded.
|
||||||
|
- Remaining worker steps: final formatting/scoped staging, commit `docs(#753): clear KBN-010 schema gate`, queue guard, push, and control-plane notification.
|
||||||
148
docs/scratchpads/755-mos-logical-identity-fencing.md
Normal file
148
docs/scratchpads/755-mos-logical-identity-fencing.md
Normal 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.
|
||||||
@@ -72,6 +72,46 @@ compatibility and canonical collision denial.
|
|||||||
- Independent security review: **APPROVE**, no verified authority/security findings on the final
|
- Independent security review: **APPROVE**, no verified authority/security findings on the final
|
||||||
delta.
|
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
|
## Risks and boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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. |
|
||||||
|
|||||||
261
packages/agent/src/connector-lease.test.ts
Normal file
261
packages/agent/src/connector-lease.test.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type {
|
||||||
|
ConnectorExecutionContext,
|
||||||
|
ConnectorExecutionGrant,
|
||||||
|
ConnectorLease,
|
||||||
|
ConnectorLeaseAuditEvent,
|
||||||
|
ConnectorLeaseStore,
|
||||||
|
FencedConnectorAdapter,
|
||||||
|
LogicalAgentBinding,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import {
|
||||||
|
ConnectorLeaseCoordinator,
|
||||||
|
MAX_CONNECTOR_GRANT_TTL_MS,
|
||||||
|
MAX_CONNECTOR_LEASE_TTL_MS,
|
||||||
|
} from './connector-lease.js';
|
||||||
|
import type { ConnectorLeaseError } from './connector-lease.js';
|
||||||
|
|
||||||
|
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
|
||||||
|
const binding: LogicalAgentBinding = { identity, bindingId: 'operator-chat' };
|
||||||
|
const activeLease: ConnectorLease = {
|
||||||
|
...binding,
|
||||||
|
leaseId: '00000000-0000-4000-8000-000000000001',
|
||||||
|
connectorId: 'connector-a',
|
||||||
|
scopes: ['runtime.send', 'tool.execute'],
|
||||||
|
leaseEpoch: '3',
|
||||||
|
acquiredAt: '2026-07-14T17:00:00.000Z',
|
||||||
|
heartbeatAt: '2026-07-14T17:00:00.000Z',
|
||||||
|
expiresAt: '2026-07-14T17:10:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
class FakeLeaseStore implements ConnectorLeaseStore {
|
||||||
|
lease: ConnectorLease | null = activeLease;
|
||||||
|
readonly audits: ConnectorLeaseAuditEvent[] = [];
|
||||||
|
|
||||||
|
async acquire(): Promise<ConnectorLease> {
|
||||||
|
if (!this.lease) throw new Error('fixture has no lease');
|
||||||
|
return this.lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
async takeover(): Promise<ConnectorLease> {
|
||||||
|
if (!this.lease) throw new Error('fixture has no lease');
|
||||||
|
return this.lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
async heartbeat(): Promise<ConnectorLease> {
|
||||||
|
if (!this.lease) throw new Error('fixture has no lease');
|
||||||
|
return this.lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
async release(): Promise<void> {}
|
||||||
|
|
||||||
|
async findCurrent(): Promise<ConnectorLease | null> {
|
||||||
|
return this.lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||||
|
this.audits.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConnectorLeaseCoordinator fencing', (): void => {
|
||||||
|
it('validates a server-minted grant immediately before invoking an adapter side effect', async (): Promise<void> => {
|
||||||
|
const store = new FakeLeaseStore();
|
||||||
|
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||||
|
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||||
|
});
|
||||||
|
const grant = await coordinator.issueGrant({
|
||||||
|
lease: activeLease,
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 30_000,
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
});
|
||||||
|
const execute = vi.fn(async (_input: string, context: ConnectorExecutionContext) => context);
|
||||||
|
const adapter: FencedConnectorAdapter<string, ConnectorExecutionContext> = { execute };
|
||||||
|
|
||||||
|
const context = await coordinator.executeGrant(grant, 'runtime.send', 'hello', adapter);
|
||||||
|
|
||||||
|
expect(execute).toHaveBeenCalledOnce();
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
identity,
|
||||||
|
bindingId: 'operator-chat',
|
||||||
|
connectorId: 'connector-a',
|
||||||
|
leaseEpoch: '3',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps grant expiry to the durable current lease instead of submitted metadata', async (): Promise<void> => {
|
||||||
|
const store = new FakeLeaseStore();
|
||||||
|
store.lease = { ...activeLease, expiresAt: '2026-07-14T17:01:05.000Z' };
|
||||||
|
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||||
|
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const grant = await coordinator.issueGrant({
|
||||||
|
lease: { ...activeLease, expiresAt: '2026-07-14T18:00:00.000Z' },
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 30_000,
|
||||||
|
correlationId: 'correlation-durable-expiry',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(grant.expiresAt).toBe('2026-07-14T17:01:05.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['forged clone', (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ ...grant })],
|
||||||
|
[
|
||||||
|
'cross-tenant clone',
|
||||||
|
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||||
|
...grant,
|
||||||
|
identity: { ...grant.identity, tenantId: 'tenant-b' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'cross-agent clone',
|
||||||
|
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||||
|
...grant,
|
||||||
|
identity: { ...grant.identity, logicalAgentId: 'other-agent' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'cross-binding clone',
|
||||||
|
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||||
|
...grant,
|
||||||
|
bindingId: 'other-binding',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'cross-connector clone',
|
||||||
|
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||||
|
...grant,
|
||||||
|
connectorId: 'connector-b',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
])('denies and audits a %s before adapter invocation', async (_label, forge): Promise<void> => {
|
||||||
|
const store = new FakeLeaseStore();
|
||||||
|
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||||
|
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||||
|
});
|
||||||
|
const grant = await coordinator.issueGrant({
|
||||||
|
lease: activeLease,
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 30_000,
|
||||||
|
correlationId: 'correlation-forged',
|
||||||
|
});
|
||||||
|
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordinator.executeGrant(forge(grant), 'runtime.send', undefined, adapter),
|
||||||
|
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
|
||||||
|
expect(adapter.execute).not.toHaveBeenCalled();
|
||||||
|
expect(store.audits.at(-1)).toMatchObject({
|
||||||
|
event: 'reject',
|
||||||
|
outcome: 'denied',
|
||||||
|
reason: 'forged_grant',
|
||||||
|
correlationId: 'correlation-forged',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies malformed forged grants with sanitized audit metadata', async (): Promise<void> => {
|
||||||
|
const store = new FakeLeaseStore();
|
||||||
|
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||||
|
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||||
|
});
|
||||||
|
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordinator.executeGrant(
|
||||||
|
// @ts-expect-error Deliberately exercise malformed runtime input at the trust boundary.
|
||||||
|
{},
|
||||||
|
'runtime.send',
|
||||||
|
undefined,
|
||||||
|
adapter,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
|
||||||
|
expect(adapter.execute).not.toHaveBeenCalled();
|
||||||
|
expect(store.audits).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
|
||||||
|
bindingId: 'untrusted',
|
||||||
|
connectorId: 'untrusted',
|
||||||
|
correlationId: 'untrusted',
|
||||||
|
reason: 'forged_grant',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects lease and grant TTLs above server-side safety caps', async (): Promise<void> => {
|
||||||
|
const store = new FakeLeaseStore();
|
||||||
|
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||||
|
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new ConnectorLeaseCoordinator(store, {
|
||||||
|
maxLeaseTtlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
|
||||||
|
}),
|
||||||
|
).toThrow(/no greater than/);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordinator.acquire({
|
||||||
|
identity,
|
||||||
|
bindingId: 'operator-chat',
|
||||||
|
connectorId: 'connector-a',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
|
||||||
|
correlationId: 'correlation-ttl',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/no greater than/);
|
||||||
|
await expect(
|
||||||
|
coordinator.issueGrant({
|
||||||
|
lease: activeLease,
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: MAX_CONNECTOR_GRANT_TTL_MS + 1,
|
||||||
|
correlationId: 'correlation-ttl',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/no greater than/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies stale epoch, expired grant, and unauthorized scope before side effects', async (): Promise<void> => {
|
||||||
|
let now = new Date('2026-07-14T17:01:00.000Z');
|
||||||
|
const store = new FakeLeaseStore();
|
||||||
|
const coordinator = new ConnectorLeaseCoordinator(store, { now: (): Date => now });
|
||||||
|
const stale = await coordinator.issueGrant({
|
||||||
|
lease: activeLease,
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 30_000,
|
||||||
|
correlationId: 'correlation-stale',
|
||||||
|
});
|
||||||
|
store.lease = { ...activeLease, leaseEpoch: '4', connectorId: 'connector-b' };
|
||||||
|
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordinator.executeGrant(stale, 'runtime.send', undefined, adapter),
|
||||||
|
).rejects.toMatchObject({ code: 'stale_epoch' } satisfies Partial<ConnectorLeaseError>);
|
||||||
|
|
||||||
|
store.lease = activeLease;
|
||||||
|
const expiring = await coordinator.issueGrant({
|
||||||
|
lease: activeLease,
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 1_000,
|
||||||
|
correlationId: 'correlation-expired',
|
||||||
|
});
|
||||||
|
now = new Date('2026-07-14T17:01:02.000Z');
|
||||||
|
await expect(
|
||||||
|
coordinator.executeGrant(expiring, 'runtime.send', undefined, adapter),
|
||||||
|
).rejects.toMatchObject({ code: 'grant_expired' } satisfies Partial<ConnectorLeaseError>);
|
||||||
|
|
||||||
|
now = new Date('2026-07-14T17:01:00.000Z');
|
||||||
|
const scoped = await coordinator.issueGrant({
|
||||||
|
lease: activeLease,
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 30_000,
|
||||||
|
correlationId: 'correlation-scope',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
coordinator.executeGrant(scoped, 'tool.execute', undefined, adapter),
|
||||||
|
).rejects.toMatchObject({ code: 'scope_denied' } satisfies Partial<ConnectorLeaseError>);
|
||||||
|
expect(adapter.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
381
packages/agent/src/connector-lease.ts
Normal file
381
packages/agent/src/connector-lease.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
normalizeConnectorId,
|
||||||
|
normalizeConnectorScope,
|
||||||
|
normalizeConnectorScopes,
|
||||||
|
normalizeCorrelationId,
|
||||||
|
normalizeLeaseEpoch,
|
||||||
|
normalizeLogicalAgentIdentity,
|
||||||
|
normalizeLogicalBindingId,
|
||||||
|
type AcquireConnectorLeaseInput,
|
||||||
|
type ConnectorExecutionContext,
|
||||||
|
type ConnectorExecutionGrant,
|
||||||
|
type ConnectorLease,
|
||||||
|
type ConnectorLeaseAuditEvent,
|
||||||
|
type ConnectorLeaseRejectReason,
|
||||||
|
type ConnectorLeaseStore,
|
||||||
|
type FencedConnectorAdapter,
|
||||||
|
type HeartbeatConnectorLeaseInput,
|
||||||
|
type IssueConnectorExecutionGrantInput,
|
||||||
|
type LogicalAgentBinding,
|
||||||
|
type ReleaseConnectorLeaseInput,
|
||||||
|
type TakeoverConnectorLeaseInput,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
export const MAX_CONNECTOR_LEASE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
export const MAX_CONNECTOR_GRANT_TTL_MS = 30 * 1000;
|
||||||
|
|
||||||
|
export interface ConnectorLeaseCoordinatorOptions {
|
||||||
|
readonly now?: () => Date;
|
||||||
|
readonly maxLeaseTtlMs?: number;
|
||||||
|
readonly maxGrantTtlMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConnectorLeaseError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: ConnectorLeaseRejectReason,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = ConnectorLeaseError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-neutral lease coordinator. Durable CAS lives in the store adapter;
|
||||||
|
* grant provenance remains process-local so a restart fails closed and mints
|
||||||
|
* fresh grants from the durable current lease.
|
||||||
|
*/
|
||||||
|
export class ConnectorLeaseCoordinator {
|
||||||
|
private readonly issuedGrants = new WeakSet<object>();
|
||||||
|
private readonly now: () => Date;
|
||||||
|
private readonly maxLeaseTtlMs: number;
|
||||||
|
private readonly maxGrantTtlMs: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly store: ConnectorLeaseStore,
|
||||||
|
options: ConnectorLeaseCoordinatorOptions = {},
|
||||||
|
) {
|
||||||
|
this.now = options.now ?? (() => new Date());
|
||||||
|
this.maxLeaseTtlMs = normalizeTtlLimit(
|
||||||
|
options.maxLeaseTtlMs ?? MAX_CONNECTOR_LEASE_TTL_MS,
|
||||||
|
MAX_CONNECTOR_LEASE_TTL_MS,
|
||||||
|
'lease',
|
||||||
|
);
|
||||||
|
this.maxGrantTtlMs = normalizeTtlLimit(
|
||||||
|
options.maxGrantTtlMs ?? MAX_CONNECTOR_GRANT_TTL_MS,
|
||||||
|
MAX_CONNECTOR_GRANT_TTL_MS,
|
||||||
|
'grant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquire(input: AcquireConnectorLeaseInput): Promise<ConnectorLease> {
|
||||||
|
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
|
||||||
|
const now = this.now();
|
||||||
|
return this.store.acquire({
|
||||||
|
...command,
|
||||||
|
leaseId: randomUUID(),
|
||||||
|
now: now.toISOString(),
|
||||||
|
expiresAt: expiresAt(now, command.ttlMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async takeover(input: TakeoverConnectorLeaseInput): Promise<ConnectorLease> {
|
||||||
|
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
|
||||||
|
const now = this.now();
|
||||||
|
return this.store.takeover({
|
||||||
|
...command,
|
||||||
|
expectedEpoch: normalizeLeaseEpoch(input.expectedEpoch),
|
||||||
|
leaseId: randomUUID(),
|
||||||
|
now: now.toISOString(),
|
||||||
|
expiresAt: expiresAt(now, command.ttlMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async heartbeat(input: HeartbeatConnectorLeaseInput): Promise<ConnectorLease> {
|
||||||
|
const now = this.now();
|
||||||
|
const lease = normalizeConnectorLease(input.lease);
|
||||||
|
const ttlMs = normalizeTtl(input.ttlMs, this.maxLeaseTtlMs, 'lease');
|
||||||
|
return this.store.heartbeat({
|
||||||
|
lease,
|
||||||
|
ttlMs,
|
||||||
|
correlationId: normalizeCorrelationId(input.correlationId),
|
||||||
|
now: now.toISOString(),
|
||||||
|
expiresAt: expiresAt(now, ttlMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async release(input: ReleaseConnectorLeaseInput): Promise<void> {
|
||||||
|
await this.store.release({
|
||||||
|
lease: normalizeConnectorLease(input.lease),
|
||||||
|
correlationId: normalizeCorrelationId(input.correlationId),
|
||||||
|
now: this.now().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async current(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
|
||||||
|
return this.store.findCurrent(normalizeBinding(binding));
|
||||||
|
}
|
||||||
|
|
||||||
|
async issueGrant(input: IssueConnectorExecutionGrantInput): Promise<ConnectorExecutionGrant> {
|
||||||
|
const now = this.now();
|
||||||
|
const lease = normalizeConnectorLease(input.lease);
|
||||||
|
const scopes = normalizeConnectorScopes(input.scopes);
|
||||||
|
const correlationId = normalizeCorrelationId(input.correlationId);
|
||||||
|
const ttlMs = normalizeTtl(input.ttlMs, this.maxGrantTtlMs, 'grant');
|
||||||
|
const current = await this.store.findCurrent(lease);
|
||||||
|
await this.assertCurrentLease(current, lease, now, correlationId);
|
||||||
|
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||||
|
if (!isScopeSubset(scopes, lease.scopes) || !isScopeSubset(scopes, current.scopes)) {
|
||||||
|
await this.reject(lease, correlationId, now, 'scope_denied');
|
||||||
|
}
|
||||||
|
const requestedExpiry = new Date(now.getTime() + ttlMs);
|
||||||
|
const leaseExpiry = new Date(current.expiresAt);
|
||||||
|
const grantExpiry = requestedExpiry < leaseExpiry ? requestedExpiry : leaseExpiry;
|
||||||
|
const grant: ConnectorExecutionGrant = Object.freeze({
|
||||||
|
identity: current.identity,
|
||||||
|
bindingId: current.bindingId,
|
||||||
|
leaseId: current.leaseId,
|
||||||
|
connectorId: current.connectorId,
|
||||||
|
scopes,
|
||||||
|
leaseEpoch: current.leaseEpoch,
|
||||||
|
issuedAt: now.toISOString(),
|
||||||
|
expiresAt: grantExpiry.toISOString(),
|
||||||
|
correlationId,
|
||||||
|
});
|
||||||
|
this.issuedGrants.add(grant);
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeGrant<TInput, TOutput>(
|
||||||
|
grant: ConnectorExecutionGrant,
|
||||||
|
requiredScope: string,
|
||||||
|
input: TInput,
|
||||||
|
adapter: FencedConnectorAdapter<TInput, TOutput>,
|
||||||
|
): Promise<TOutput> {
|
||||||
|
const now = this.now();
|
||||||
|
const normalizedScope = normalizeConnectorScope(requiredScope);
|
||||||
|
if (!this.issuedGrants.has(grant)) {
|
||||||
|
await this.rejectForgedGrant(grant, now);
|
||||||
|
}
|
||||||
|
if (new Date(grant.expiresAt) <= now) {
|
||||||
|
await this.rejectGrant(grant, now, 'grant_expired');
|
||||||
|
}
|
||||||
|
const current = await this.store.findCurrent(normalizeBinding(grant));
|
||||||
|
await this.assertCurrentLease(current, grant, now, grant.correlationId);
|
||||||
|
if (!grant.scopes.includes(normalizedScope) || !current?.scopes.includes(normalizedScope)) {
|
||||||
|
await this.rejectGrant(grant, now, 'scope_denied');
|
||||||
|
}
|
||||||
|
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||||
|
const context: ConnectorExecutionContext = Object.freeze({
|
||||||
|
identity: current.identity,
|
||||||
|
bindingId: current.bindingId,
|
||||||
|
leaseId: current.leaseId,
|
||||||
|
connectorId: current.connectorId,
|
||||||
|
scopes: Object.freeze([...grant.scopes]),
|
||||||
|
leaseEpoch: current.leaseEpoch,
|
||||||
|
correlationId: grant.correlationId,
|
||||||
|
grantExpiresAt: grant.expiresAt,
|
||||||
|
});
|
||||||
|
return adapter.execute(input, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCurrentLease(
|
||||||
|
current: ConnectorLease | null,
|
||||||
|
authority: ConnectorLease | ConnectorExecutionGrant,
|
||||||
|
now: Date,
|
||||||
|
correlationId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!current) await this.reject(authority, correlationId, now, 'lease_missing');
|
||||||
|
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||||
|
if (current.releasedAt) await this.reject(authority, correlationId, now, 'lease_released');
|
||||||
|
if (new Date(current.expiresAt) <= now) {
|
||||||
|
await this.store.recordAudit(auditEvent(current, correlationId, now, 'expiry', 'succeeded'));
|
||||||
|
await this.reject(authority, correlationId, now, 'lease_expired');
|
||||||
|
}
|
||||||
|
if (current.leaseEpoch !== authority.leaseEpoch) {
|
||||||
|
await this.reject(authority, correlationId, now, 'stale_epoch');
|
||||||
|
}
|
||||||
|
if (current.leaseId !== authority.leaseId || current.connectorId !== authority.connectorId) {
|
||||||
|
await this.reject(authority, correlationId, now, 'connector_mismatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rejectGrant(
|
||||||
|
grant: ConnectorExecutionGrant,
|
||||||
|
now: Date,
|
||||||
|
reason: ConnectorLeaseRejectReason,
|
||||||
|
): Promise<never> {
|
||||||
|
return this.reject(grant, grant.correlationId, now, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rejectForgedGrant(grant: unknown, now: Date): Promise<never> {
|
||||||
|
const event = safeForgedGrantAudit(grant, now);
|
||||||
|
await this.store.recordAudit(event);
|
||||||
|
throw new ConnectorLeaseError('forged_grant', safeReasonMessage('forged_grant'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reject(
|
||||||
|
authority: LogicalAgentBinding & {
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly leaseId?: string;
|
||||||
|
readonly leaseEpoch?: string;
|
||||||
|
},
|
||||||
|
correlationId: string,
|
||||||
|
now: Date,
|
||||||
|
reason: ConnectorLeaseRejectReason,
|
||||||
|
): Promise<never> {
|
||||||
|
await this.store.recordAudit(
|
||||||
|
auditEvent(authority, correlationId, now, 'reject', 'denied', reason),
|
||||||
|
);
|
||||||
|
throw new ConnectorLeaseError(reason, safeReasonMessage(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAcquireInput(
|
||||||
|
input: AcquireConnectorLeaseInput,
|
||||||
|
maxLeaseTtlMs: number,
|
||||||
|
): AcquireConnectorLeaseInput {
|
||||||
|
return {
|
||||||
|
identity: normalizeLogicalAgentIdentity(input.identity),
|
||||||
|
bindingId: normalizeLogicalBindingId(input.bindingId),
|
||||||
|
connectorId: normalizeConnectorId(input.connectorId),
|
||||||
|
scopes: normalizeConnectorScopes(input.scopes),
|
||||||
|
ttlMs: normalizeTtl(input.ttlMs, maxLeaseTtlMs, 'lease'),
|
||||||
|
correlationId: normalizeCorrelationId(input.correlationId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBinding(input: LogicalAgentBinding): LogicalAgentBinding {
|
||||||
|
return {
|
||||||
|
identity: normalizeLogicalAgentIdentity(input.identity),
|
||||||
|
bindingId: normalizeLogicalBindingId(input.bindingId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConnectorLease(lease: ConnectorLease): ConnectorLease {
|
||||||
|
const binding = normalizeBinding(lease);
|
||||||
|
return Object.freeze({
|
||||||
|
...binding,
|
||||||
|
leaseId: lease.leaseId,
|
||||||
|
connectorId: normalizeConnectorId(lease.connectorId),
|
||||||
|
scopes: normalizeConnectorScopes(lease.scopes),
|
||||||
|
leaseEpoch: normalizeLeaseEpoch(lease.leaseEpoch),
|
||||||
|
acquiredAt: normalizeTimestamp(lease.acquiredAt, 'lease acquisition'),
|
||||||
|
heartbeatAt: normalizeTimestamp(lease.heartbeatAt, 'lease heartbeat'),
|
||||||
|
expiresAt: normalizeTimestamp(lease.expiresAt, 'lease expiry'),
|
||||||
|
...(lease.releasedAt
|
||||||
|
? { releasedAt: normalizeTimestamp(lease.releasedAt, 'lease release') }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTtl(ttlMs: number, maximum: number, kind: 'lease' | 'grant'): number {
|
||||||
|
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > maximum) {
|
||||||
|
throw new Error(
|
||||||
|
`Connector ${kind} TTL must be a positive safe integer no greater than ${maximum}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTtlLimit(ttlMs: number, hardMaximum: number, kind: 'lease' | 'grant'): number {
|
||||||
|
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > hardMaximum) {
|
||||||
|
throw new Error(
|
||||||
|
`Maximum connector ${kind} TTL must be a positive safe integer no greater than ${hardMaximum}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimestamp(value: string, label: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) throw new Error(`${label} timestamp is invalid`);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expiresAt(now: Date, ttlMs: number): string {
|
||||||
|
const expiry = new Date(now.getTime() + ttlMs);
|
||||||
|
if (Number.isNaN(expiry.getTime())) throw new Error('Connector lease TTL exceeds date range');
|
||||||
|
return expiry.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScopeSubset(requested: readonly string[], allowed: readonly string[]): boolean {
|
||||||
|
return requested.every((scope) => allowed.includes(scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditEvent(
|
||||||
|
authority: LogicalAgentBinding & {
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly leaseId?: string;
|
||||||
|
readonly leaseEpoch?: string;
|
||||||
|
},
|
||||||
|
correlationId: string,
|
||||||
|
now: Date,
|
||||||
|
event: ConnectorLeaseAuditEvent['event'],
|
||||||
|
outcome: ConnectorLeaseAuditEvent['outcome'],
|
||||||
|
reason?: ConnectorLeaseRejectReason,
|
||||||
|
): ConnectorLeaseAuditEvent {
|
||||||
|
return {
|
||||||
|
identity: authority.identity,
|
||||||
|
bindingId: authority.bindingId,
|
||||||
|
connectorId: authority.connectorId,
|
||||||
|
correlationId,
|
||||||
|
occurredAt: now.toISOString(),
|
||||||
|
event,
|
||||||
|
outcome,
|
||||||
|
...(authority.leaseId ? { leaseId: authority.leaseId } : {}),
|
||||||
|
...(authority.leaseEpoch ? { leaseEpoch: authority.leaseEpoch } : {}),
|
||||||
|
...(reason ? { reason } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeForgedGrantAudit(grant: unknown, now: Date): ConnectorLeaseAuditEvent {
|
||||||
|
const fallback: ConnectorLeaseAuditEvent = {
|
||||||
|
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
|
||||||
|
bindingId: 'untrusted',
|
||||||
|
connectorId: 'untrusted',
|
||||||
|
correlationId: 'untrusted',
|
||||||
|
occurredAt: now.toISOString(),
|
||||||
|
event: 'reject',
|
||||||
|
outcome: 'denied',
|
||||||
|
reason: 'forged_grant',
|
||||||
|
};
|
||||||
|
if (typeof grant !== 'object' || grant === null || !('identity' in grant)) return fallback;
|
||||||
|
const identity = grant.identity;
|
||||||
|
if (typeof identity !== 'object' || identity === null) return fallback;
|
||||||
|
if (!('tenantId' in identity) || !('logicalAgentId' in identity)) return fallback;
|
||||||
|
if (!('bindingId' in grant) || !('connectorId' in grant) || !('correlationId' in grant)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof identity.tenantId !== 'string' ||
|
||||||
|
typeof identity.logicalAgentId !== 'string' ||
|
||||||
|
typeof grant.bindingId !== 'string' ||
|
||||||
|
typeof grant.connectorId !== 'string' ||
|
||||||
|
typeof grant.correlationId !== 'string'
|
||||||
|
) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
identity: normalizeLogicalAgentIdentity({
|
||||||
|
tenantId: identity.tenantId,
|
||||||
|
logicalAgentId: identity.logicalAgentId,
|
||||||
|
}),
|
||||||
|
bindingId: normalizeLogicalBindingId(grant.bindingId),
|
||||||
|
connectorId: normalizeConnectorId(grant.connectorId),
|
||||||
|
correlationId: normalizeCorrelationId(grant.correlationId),
|
||||||
|
occurredAt: now.toISOString(),
|
||||||
|
event: 'reject',
|
||||||
|
outcome: 'denied',
|
||||||
|
reason: 'forged_grant',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeReasonMessage(reason: ConnectorLeaseRejectReason): string {
|
||||||
|
return `Connector authority denied: ${reason}`;
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ export * from './tmux-fleet-runtime-provider.js';
|
|||||||
export * from './hermes-runtime-provider.js';
|
export * from './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';
|
||||||
|
|||||||
36
packages/db/drizzle/0016_salty_morlocks.sql
Normal file
36
packages/db/drizzle/0016_salty_morlocks.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
CREATE TABLE "connector_lease_audit_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" text NOT NULL,
|
||||||
|
"logical_agent_id" text NOT NULL,
|
||||||
|
"binding_id" text NOT NULL,
|
||||||
|
"connector_id" text NOT NULL,
|
||||||
|
"lease_id" uuid,
|
||||||
|
"lease_epoch" bigint,
|
||||||
|
"event" text NOT NULL,
|
||||||
|
"outcome" text NOT NULL,
|
||||||
|
"reason" text,
|
||||||
|
"correlation_id" text NOT NULL,
|
||||||
|
"occurred_at" timestamp with time zone NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "logical_agent_connector_leases" (
|
||||||
|
"lease_id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"tenant_id" text NOT NULL,
|
||||||
|
"logical_agent_id" text NOT NULL,
|
||||||
|
"binding_id" text NOT NULL,
|
||||||
|
"connector_id" text NOT NULL,
|
||||||
|
"scopes" jsonb NOT NULL,
|
||||||
|
"lease_epoch" bigint NOT NULL,
|
||||||
|
"acquired_at" timestamp with time zone NOT NULL,
|
||||||
|
"heartbeat_at" timestamp with time zone NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"released_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "connector_lease_audit_binding_occurred_idx" ON "connector_lease_audit_log" USING btree ("tenant_id","logical_agent_id","binding_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint
|
||||||
|
CREATE INDEX "connector_lease_audit_correlation_idx" ON "connector_lease_audit_log" USING btree ("correlation_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "logical_agent_connector_lease_binding_idx" ON "logical_agent_connector_leases" USING btree ("tenant_id","logical_agent_id","binding_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "logical_agent_connector_lease_expiry_idx" ON "logical_agent_connector_leases" USING btree ("expires_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "logical_agent_connector_lease_connector_idx" ON "logical_agent_connector_leases" USING btree ("connector_id");
|
||||||
4530
packages/db/drizzle/meta/0016_snapshot.json
Normal file
4530
packages/db/drizzle/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
|||||||
"when": 1783942610000,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
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,
|
authorityForCanonicalClass,
|
||||||
canonicalizeRoleClass,
|
canonicalizeRoleClass,
|
||||||
extractClassesFromDir,
|
extractClassesFromDir,
|
||||||
|
extractClassesFromDirSync,
|
||||||
listPersonaClasses,
|
listPersonaClasses,
|
||||||
personaStatus,
|
personaStatus,
|
||||||
resolvePersona,
|
resolvePersona,
|
||||||
|
resolvePersonaFrom,
|
||||||
resolvePersonaSync,
|
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';
|
||||||
@@ -69,7 +71,7 @@ describe('FCM class canonicalization and authority', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron'])(
|
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron', 'constructor'])(
|
||||||
'does not infer an alias for %s',
|
'does not infer an alias for %s',
|
||||||
(requested: string) => {
|
(requested: string) => {
|
||||||
expect(canonicalizeRoleClass(requested)).toEqual({
|
expect(canonicalizeRoleClass(requested)).toEqual({
|
||||||
@@ -79,6 +81,28 @@ describe('FCM class canonicalization and authority', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it('canonicalizes before override lookup and lets the canonical override win', async () => {
|
||||||
await mkdir(overrideDir, { recursive: true });
|
await mkdir(overrideDir, { recursive: true });
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -174,12 +198,17 @@ describe('required FCM role library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,8 +53,6 @@ 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';
|
||||||
@@ -73,7 +71,9 @@ export interface CanonicalRoleClass {
|
|||||||
/** Canonicalize only the three explicitly approved compatibility aliases. */
|
/** Canonicalize only the three explicitly approved compatibility aliases. */
|
||||||
export function canonicalizeRoleClass(requestedClass: string): CanonicalRoleClass {
|
export function canonicalizeRoleClass(requestedClass: string): CanonicalRoleClass {
|
||||||
const requested = requestedClass.trim();
|
const requested = requestedClass.trim();
|
||||||
const canonical = ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES] ?? requested;
|
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 });
|
return Object.freeze({ requestedClass: requested, canonicalClass: canonical });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,9 @@ export const ROLE_AUTHORITY_BY_CANONICAL_CLASS: Readonly<Record<string, RoleAuth
|
|||||||
|
|
||||||
/** Return protected authority for an already-canonical class; custom classes get none. */
|
/** Return protected authority for an already-canonical class; custom classes get none. */
|
||||||
export function authorityForCanonicalClass(canonicalClass: string): RoleAuthority {
|
export function authorityForCanonicalClass(canonicalClass: string): RoleAuthority {
|
||||||
return ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass] ?? NO_PROTECTED_AUTHORITY;
|
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). */
|
||||||
@@ -131,41 +133,56 @@ 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');
|
||||||
@@ -184,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');
|
||||||
@@ -205,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
|
||||||
@@ -213,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) });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,19 +347,13 @@ 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';
|
||||||
@@ -287,6 +368,118 @@ export interface PersonaResolution extends CanonicalRoleClass {
|
|||||||
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:`
|
||||||
@@ -320,53 +513,25 @@ export async function resolvePersonaFrom(
|
|||||||
canonicalizeRoleClass(requestedClass);
|
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 {
|
|
||||||
requestedClass: requested,
|
|
||||||
canonicalClass: klass,
|
|
||||||
klass,
|
|
||||||
layer,
|
|
||||||
file: byName,
|
|
||||||
content,
|
|
||||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const content = await readFile(pf.file, 'utf8');
|
|
||||||
return {
|
|
||||||
requestedClass: requested,
|
|
||||||
canonicalClass: klass,
|
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -387,49 +552,46 @@ export function resolvePersonaSync(
|
|||||||
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 {
|
|
||||||
requestedClass: requested,
|
|
||||||
canonicalClass: klass,
|
|
||||||
klass,
|
|
||||||
layer,
|
|
||||||
file: byName,
|
|
||||||
content,
|
|
||||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
try {
|
|
||||||
const content = readFileSync(pf.file, 'utf8');
|
|
||||||
return {
|
|
||||||
requestedClass: requested,
|
|
||||||
canonicalClass: klass,
|
|
||||||
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 {
|
||||||
@@ -446,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
packages/mosaic/src/fleet/example-profile-dispositions.ts
Normal file
121
packages/mosaic/src/fleet/example-profile-dispositions.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -132,16 +132,10 @@ describe('roster v2 semantic validation', (): void => {
|
|||||||
|
|
||||||
it('rejects an unreadable resolved persona', async () => {
|
it('rejects an unreadable resolved persona', async () => {
|
||||||
const dirs = await semanticDirs();
|
const dirs = await semanticDirs();
|
||||||
const file = join(dirs.overrideDir, 'worker.md');
|
await mkdir(join(dirs.overrideDir, 'worker.md'));
|
||||||
await writeFile(file, '# worker\n\n(`class: worker`)\n', 'utf8');
|
|
||||||
await chmod(file, 0o000);
|
|
||||||
try {
|
|
||||||
await expect(
|
await expect(
|
||||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs),
|
validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs),
|
||||||
).rejects.toThrow(/readable persona/i);
|
).rejects.toThrow(/readable persona/i);
|
||||||
} finally {
|
|
||||||
await chmod(file, 0o600);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
59
packages/types/src/agent/connector-lease.dto.spec.ts
Normal file
59
packages/types/src/agent/connector-lease.dto.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
normalizeConnectorId,
|
||||||
|
normalizeConnectorScopes,
|
||||||
|
normalizeLeaseEpoch,
|
||||||
|
normalizeLogicalAgentIdentity,
|
||||||
|
normalizeLogicalBindingId,
|
||||||
|
type ConnectorExecutionContext,
|
||||||
|
type LogicalAgentIdentity,
|
||||||
|
} from './connector-lease.dto.js';
|
||||||
|
|
||||||
|
describe('logical agent connector lease contract', (): void => {
|
||||||
|
it('normalizes a runtime-neutral logical identity and binding vocabulary', (): void => {
|
||||||
|
const identity = normalizeLogicalAgentIdentity({
|
||||||
|
tenantId: ' tenant-01 ',
|
||||||
|
logicalAgentId: ' MOS.Primary ',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(identity).toEqual({ tenantId: 'tenant-01', logicalAgentId: 'mos.primary' });
|
||||||
|
expect(normalizeLogicalBindingId(' Discord:Operations ')).toBe('discord:operations');
|
||||||
|
expect(normalizeConnectorId(' PI.Worker-01 ')).toBe('pi.worker-01');
|
||||||
|
expect(Object.isFrozen(identity)).toBe(true);
|
||||||
|
expect(Object.keys(identity).sort()).toEqual(['logicalAgentId', 'tenantId']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalizes scopes and decimal fencing epochs', (): void => {
|
||||||
|
expect(normalizeConnectorScopes([' Runtime.Send ', 'tool.execute', 'runtime.send'])).toEqual([
|
||||||
|
'runtime.send',
|
||||||
|
'tool.execute',
|
||||||
|
]);
|
||||||
|
expect(normalizeLeaseEpoch('00042')).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['', 'mos'],
|
||||||
|
['tenant', ''],
|
||||||
|
['tenant', 'claude session/123'],
|
||||||
|
])('rejects ambiguous identity values tenant=%j agent=%j', (tenantId, logicalAgentId): void => {
|
||||||
|
expect(() => normalizeLogicalAgentIdentity({ tenantId, logicalAgentId })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines an adapter context without harness-native identity fields', (): void => {
|
||||||
|
const identity: LogicalAgentIdentity = { tenantId: 'tenant-01', logicalAgentId: 'mos' };
|
||||||
|
const context: ConnectorExecutionContext = {
|
||||||
|
identity,
|
||||||
|
bindingId: 'operator-chat',
|
||||||
|
connectorId: 'connector-a',
|
||||||
|
leaseId: '00000000-0000-4000-8000-000000000001',
|
||||||
|
leaseEpoch: '7',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
grantExpiresAt: '2026-07-14T18:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(context).not.toHaveProperty('sessionId');
|
||||||
|
expect(context).not.toHaveProperty('tmuxSession');
|
||||||
|
expect(context).not.toHaveProperty('providerSessionId');
|
||||||
|
});
|
||||||
|
});
|
||||||
199
packages/types/src/agent/connector-lease.dto.ts
Normal file
199
packages/types/src/agent/connector-lease.dto.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
const ID_PATTERN = /^[a-z0-9][a-z0-9._:@-]{0,127}$/;
|
||||||
|
const TENANT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/;
|
||||||
|
const SCOPE_PATTERN = /^[a-z][a-z0-9._:-]{0,127}$/;
|
||||||
|
|
||||||
|
/** Stable Mosaic identity. It intentionally contains no runtime/provider session identifier. */
|
||||||
|
export interface LogicalAgentIdentity {
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly logicalAgentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogicalAgentBinding {
|
||||||
|
readonly identity: LogicalAgentIdentity;
|
||||||
|
readonly bindingId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorLease extends LogicalAgentBinding {
|
||||||
|
readonly leaseId: string;
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly scopes: readonly string[];
|
||||||
|
readonly leaseEpoch: string;
|
||||||
|
readonly acquiredAt: string;
|
||||||
|
readonly heartbeatAt: string;
|
||||||
|
readonly expiresAt: string;
|
||||||
|
readonly releasedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcquireConnectorLeaseInput {
|
||||||
|
readonly identity: LogicalAgentIdentity;
|
||||||
|
readonly bindingId: string;
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly scopes: readonly string[];
|
||||||
|
readonly ttlMs: number;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TakeoverConnectorLeaseInput extends AcquireConnectorLeaseInput {
|
||||||
|
readonly expectedEpoch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatConnectorLeaseInput {
|
||||||
|
readonly lease: ConnectorLease;
|
||||||
|
readonly ttlMs: number;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseConnectorLeaseInput {
|
||||||
|
readonly lease: ConnectorLease;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueConnectorExecutionGrantInput {
|
||||||
|
readonly lease: ConnectorLease;
|
||||||
|
readonly scopes: readonly string[];
|
||||||
|
readonly ttlMs: number;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal server grant. Object provenance is checked in addition to these fields. */
|
||||||
|
export interface ConnectorExecutionGrant extends LogicalAgentBinding {
|
||||||
|
readonly leaseId: string;
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly scopes: readonly string[];
|
||||||
|
readonly leaseEpoch: string;
|
||||||
|
readonly issuedAt: string;
|
||||||
|
readonly expiresAt: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized context passed to an adapter only after current-lease validation. */
|
||||||
|
export interface ConnectorExecutionContext extends LogicalAgentBinding {
|
||||||
|
readonly leaseId: string;
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly scopes: readonly string[];
|
||||||
|
readonly leaseEpoch: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly grantExpiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FencedConnectorAdapter<TInput, TOutput> {
|
||||||
|
execute(input: TInput, context: ConnectorExecutionContext): Promise<TOutput>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectorLeaseAuditEventType =
|
||||||
|
| 'acquire'
|
||||||
|
| 'renew'
|
||||||
|
| 'takeover'
|
||||||
|
| 'reject'
|
||||||
|
| 'release'
|
||||||
|
| 'expiry';
|
||||||
|
export type ConnectorLeaseAuditOutcome = 'succeeded' | 'denied';
|
||||||
|
export type ConnectorLeaseRejectReason =
|
||||||
|
| 'policy_denied'
|
||||||
|
| 'lease_held'
|
||||||
|
| 'takeover_required'
|
||||||
|
| 'cas_mismatch'
|
||||||
|
| 'lease_missing'
|
||||||
|
| 'lease_released'
|
||||||
|
| 'lease_expired'
|
||||||
|
| 'stale_epoch'
|
||||||
|
| 'connector_mismatch'
|
||||||
|
| 'scope_denied'
|
||||||
|
| 'forged_grant'
|
||||||
|
| 'grant_expired';
|
||||||
|
|
||||||
|
/** Credential-safe metadata only: no grant object, scope set, payload, token, or approval ref. */
|
||||||
|
export interface ConnectorLeaseAuditEvent extends LogicalAgentBinding {
|
||||||
|
readonly event: ConnectorLeaseAuditEventType;
|
||||||
|
readonly outcome: ConnectorLeaseAuditOutcome;
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly occurredAt: string;
|
||||||
|
readonly leaseId?: string;
|
||||||
|
readonly leaseEpoch?: string;
|
||||||
|
readonly reason?: ConnectorLeaseRejectReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorLeaseAcquireMutation extends AcquireConnectorLeaseInput {
|
||||||
|
readonly leaseId: string;
|
||||||
|
readonly now: string;
|
||||||
|
readonly expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorLeaseTakeoverMutation extends TakeoverConnectorLeaseInput {
|
||||||
|
readonly leaseId: string;
|
||||||
|
readonly now: string;
|
||||||
|
readonly expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorLeaseHeartbeatMutation extends HeartbeatConnectorLeaseInput {
|
||||||
|
readonly now: string;
|
||||||
|
readonly expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorLeaseReleaseMutation extends ReleaseConnectorLeaseInput {
|
||||||
|
readonly now: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorLeaseStore {
|
||||||
|
acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease>;
|
||||||
|
takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease>;
|
||||||
|
heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease>;
|
||||||
|
release(input: ConnectorLeaseReleaseMutation): Promise<void>;
|
||||||
|
findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null>;
|
||||||
|
recordAudit(event: ConnectorLeaseAuditEvent): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLogicalAgentIdentity(input: LogicalAgentIdentity): LogicalAgentIdentity {
|
||||||
|
const tenantId = requiredIdentifier(input.tenantId, 'tenant ID', TENANT_PATTERN, false);
|
||||||
|
const logicalAgentId = requiredIdentifier(
|
||||||
|
input.logicalAgentId,
|
||||||
|
'logical agent ID',
|
||||||
|
ID_PATTERN,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return Object.freeze({ tenantId, logicalAgentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLogicalBindingId(value: string): string {
|
||||||
|
return requiredIdentifier(value, 'logical binding ID', ID_PATTERN, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConnectorId(value: string): string {
|
||||||
|
return requiredIdentifier(value, 'connector ID', ID_PATTERN, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCorrelationId(value: string): string {
|
||||||
|
return requiredIdentifier(value, 'correlation ID', TENANT_PATTERN, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConnectorScope(value: string): string {
|
||||||
|
return requiredIdentifier(value, 'connector scope', SCOPE_PATTERN, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConnectorScopes(values: readonly string[]): readonly string[] {
|
||||||
|
if (values.length === 0) throw new Error('At least one connector scope is required');
|
||||||
|
return Object.freeze(Array.from(new Set(values.map(normalizeConnectorScope))).sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLeaseEpoch(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!/^\d+$/.test(trimmed)) throw new Error('Lease epoch must be a positive decimal integer');
|
||||||
|
const epoch = BigInt(trimmed);
|
||||||
|
if (epoch < 1n) throw new Error('Lease epoch must be a positive decimal integer');
|
||||||
|
return epoch.toString(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredIdentifier(
|
||||||
|
value: string,
|
||||||
|
label: string,
|
||||||
|
pattern: RegExp,
|
||||||
|
lowerCase: boolean,
|
||||||
|
): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const normalized = lowerCase ? trimmed.toLowerCase() : trimmed;
|
||||||
|
if (!pattern.test(normalized)) {
|
||||||
|
throw new Error(`${label} has an invalid normalized format`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export interface AgentSessionHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export * from './agent-runtime-provider.js';
|
export * from './agent-runtime-provider.js';
|
||||||
|
export * from './connector-lease.dto.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user