diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 0ec2cef..36169cd 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -21,6 +21,12 @@ import { LogModule } from '../log/log.module.js'; import { CommandsModule } from '../commands/commands.module.js'; import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js'; import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; +import { + CONNECTOR_LEASE_POLICY, + ConnectorLeaseService, + DenyConnectorLeasePolicy, +} from './connector-lease.service.js'; import { AGENT_RUNTIME_PROVIDER_REGISTRY, RUNTIME_APPROVAL_VERIFIER, @@ -46,6 +52,13 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi SkillLoaderService, DurableSessionRepository, DurableSessionService, + ConnectorLeaseRepository, + DenyConnectorLeasePolicy, + { + provide: CONNECTOR_LEASE_POLICY, + useExisting: DenyConnectorLeasePolicy, + }, + ConnectorLeaseService, { provide: AGENT_RUNTIME_PROVIDER_REGISTRY, useFactory: createGatewayRuntimeProviderRegistry, @@ -78,6 +91,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi SkillLoaderService, DurableSessionService, RuntimeProviderService, + ConnectorLeaseService, AGENT_RUNTIME_PROVIDER_REGISTRY, ], }) diff --git a/apps/gateway/src/agent/connector-lease.integration.test.ts b/apps/gateway/src/agent/connector-lease.integration.test.ts new file mode 100644 index 0000000..a54c123 --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.integration.test.ts @@ -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 => { + 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 => { + 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 => { + 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 = { 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 => { + 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 => { + 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 => { + 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(); + }); +}); diff --git a/apps/gateway/src/agent/connector-lease.postgres.integration.test.ts b/apps/gateway/src/agent/connector-lease.postgres.integration.test.ts new file mode 100644 index 0000000..feebfc7 --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.postgres.integration.test.ts @@ -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 => { + 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 => { + 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' }); + }); +}); diff --git a/apps/gateway/src/agent/connector-lease.repository.test.ts b/apps/gateway/src/agent/connector-lease.repository.test.ts new file mode 100644 index 0000000..999257b --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.repository.test.ts @@ -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 => { + 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 => { + await handle.close(); + await rm(dataDir, { recursive: true, force: true }); + }); + + it('allows only one concurrent contender to acquire a binding', async (): Promise => { + 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, + }); + }); + + it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise => { + 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, + }); + }); + + it('heartbeats and releases only the current connector epoch', async (): Promise => { + 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); + }); + + it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise => { + 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); + 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 => { + 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); + }); +}); diff --git a/apps/gateway/src/agent/connector-lease.repository.ts b/apps/gateway/src/agent/connector-lease.repository.ts new file mode 100644 index 0000000..fd450ea --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.repository.ts @@ -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 { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + 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 { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + 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 { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + 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 { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + 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 { + const row = await findRow(this.db, binding); + return row ? toLease(row) : null; + } + + async recordAudit(event: ConnectorLeaseAuditEvent): Promise { + 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, + binding: LogicalAgentBinding, +): Promise { + 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 { + 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, event: ConnectorLeaseAuditEvent): Promise { + 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), + }); +} diff --git a/apps/gateway/src/agent/connector-lease.service.ts b/apps/gateway/src/agent/connector-lease.service.ts new file mode 100644 index 0000000..a6db312 --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.service.ts @@ -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; +} + +/** M1 has no concrete cutover policy: unconfigured production use fails closed. */ +@Injectable() +export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy { + async authorize(_subject: ConnectorLeasePolicySubject): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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( + grant: ConnectorExecutionGrant, + requiredScope: string, + input: TInput, + adapter: FencedConnectorAdapter, + ): Promise { + return this.coordinator.executeGrant(grant, requiredScope, input, adapter); + } + + private command( + request: GatewayConnectorLeaseRequest, + context: ConnectorLeaseRequestContext, + ): Omit { + 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, + context: ConnectorLeaseRequestContext, + ): Promise { + 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 { + 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, + context: ConnectorLeaseRequestContext, + requestedScopes: readonly string[], + requestedTtlMs: number | null, + ): Promise { + 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, + context: ConnectorLeaseRequestContext, + ): Promise { + 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]; + }) + ); +} diff --git a/docs/PRD.md b/docs/PRD.md index ddee2d7..7585ecc 100644 --- a/docs/PRD.md +++ b/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 ### High-Level System Diagram diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 4644e2e..487df03 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -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. - [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754) - [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755) +- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md) +- [M1 connector lease operations](guides/mos-connector-lease-operations.md) diff --git a/docs/architecture/mos-runtime-portability-m1.md b/docs/architecture/mos-runtime-portability-m1.md new file mode 100644 index 0000000..932d10f --- /dev/null +++ b/docs/architecture/mos-runtime-portability-m1.md @@ -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. diff --git a/docs/guides/mos-connector-lease-operations.md b/docs/guides/mos-connector-lease-operations.md new file mode 100644 index 0000000..99ef63b --- /dev/null +++ b/docs/guides/mos-connector-lease-operations.md @@ -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. diff --git a/docs/scratchpads/755-mos-logical-identity-fencing.md b/docs/scratchpads/755-mos-logical-identity-fencing.md new file mode 100644 index 0000000..7e18c9f --- /dev/null +++ b/docs/scratchpads/755-mos-logical-identity-fencing.md @@ -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. diff --git a/docs/tess/TASKS.md b/docs/tess/TASKS.md index dd729ef..6becd14 100644 --- a/docs/tess/TASKS.md +++ b/docs/tess/TASKS.md @@ -43,3 +43,4 @@ | TESS-M5-002 | done | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | coder3 | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001. **Mos-DISPATCHED to coder3 2026-07-13** ("M4 complete; advancing to M5") — dispatched AHEAD of TESS-M4-V passing; the M4-V-status-vs-#710-CLOSED dependency reconciliation is pending Mos ruling (tracked, not orchestrator-decided). **DELIVERED as PR #742** — 4 new files docs/tess/M5-MIGRATION-{INVENTORY,CUTOVER,ROLLBACK,RETENTION-DEPRECATION}.md, base main b7b0f508, frozen head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd. Docs-only; tracking-control trio (MISSION-MANIFEST/TASKS/VERIFICATION-MATRIX) UNTOUCHED; command-authz byte-identical a9f829e7; no live creds. **CI pipeline 1773 SUCCESS** (repo 47, refs/pull/742/head, commit==head). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd (Gitea comment 17108)** — evidence claims verified to trace to landed Hermes adapter / capability matrix, gateway registry/reachability, operator-memory scope path, Mos coordination boundary; docs do NOT over-claim transcript/profile import, schema migration, unsupported-capability enablement, production cutover, or deprecation completion. Head independently verified UNMOVED at b5e9d0e528a5 post-ROR (live Gitea), base main, mergeable=true. **#742 MERGED by Mos → main 5789711e. Deliverable docs LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** | | TESS-M5-003 | done | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate. **DELIVERED as PR #746** (branch feat/tess-docs, base main, 7 docs-only files: docs/openapi-tess.yaml + docs/tess/{ADMIN,DEVELOPER,OPERATIONS,PLUGIN,USER}-GUIDE.md + M5-003-DOCUMENTATION-CHECKLIST.md). 4-round revise-loop (heads 470eb911→c9f69300→7aea94e2→25b9d642) converged: OpenAPI covers interaction routes + SSE /sessions/{sessionId}/stream + Mos coord (/api/coord/mos/handoff,/observe,/result) + memory preferences/insights/search; request-body schemas aligned to real DTOs (Send requires content+idempotencyKey, Stop requires approvalRef, Insight requires only content, MosHandoff body requires idempotencyKey+summary); checklist accurate. **CI pipeline 1786 SUCCESS** (repo 47, refs/pull/746/head, commit==head 25b9d642). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head 25b9d642b939014b3efd61826e7524cafc6ffc2e (Gitea comment 17170)** — docs-only, tracking-trio untouched, command-authz byte-identical a9f829e7, no false coverage claims. Head verified UNMOVED at 25b9d642 (live Gitea, not worker-reported), base main, mergeable=true. **#746 MERGED by Mos → main bc8016c8314ec3a4b6ebc2fec5d9f276fca3327a. Documentation gate LANDED.** GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending. | | TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence | +| MOS-PORT-M1-001 | in-progress | Implement logical Mos identity, PostgreSQL connector lease, monotonic fencing, server-bound execution grants, audit, migrations, concurrency/restart/abuse/integration tests | #755 | codex | packages/types, packages/agent, packages/db, apps/gateway | feat/mos-logical-identity-fencing | — | 38K | Requirements MOS-PORT-ID-001, MOS-PORT-LEASE-001, MOS-PORT-FENCE-001..002, MOS-PORT-OBS-001, MOS-PORT-ARCH-001. One Sol/Pi worker; TDD; PR-open STOP; worker must not edit this ledger. | diff --git a/packages/agent/src/connector-lease.test.ts b/packages/agent/src/connector-lease.test.ts new file mode 100644 index 0000000..2120eea --- /dev/null +++ b/packages/agent/src/connector-lease.test.ts @@ -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 { + if (!this.lease) throw new Error('fixture has no lease'); + return this.lease; + } + + async takeover(): Promise { + if (!this.lease) throw new Error('fixture has no lease'); + return this.lease; + } + + async heartbeat(): Promise { + if (!this.lease) throw new Error('fixture has no lease'); + return this.lease; + } + + async release(): Promise {} + + async findCurrent(): Promise { + return this.lease; + } + + async recordAudit(event: ConnectorLeaseAuditEvent): Promise { + this.audits.push(event); + } +} + +describe('ConnectorLeaseCoordinator fencing', (): void => { + it('validates a server-minted grant immediately before invoking an adapter side effect', async (): Promise => { + 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 = { 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 => { + 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 => { + 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); + 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 => { + 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); + 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 => { + 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 => { + 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); + + 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); + + 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); + expect(adapter.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent/src/connector-lease.ts b/packages/agent/src/connector-lease.ts new file mode 100644 index 0000000..7283bdb --- /dev/null +++ b/packages/agent/src/connector-lease.ts @@ -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(); + 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 { + 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 { + 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 { + 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 { + await this.store.release({ + lease: normalizeConnectorLease(input.lease), + correlationId: normalizeCorrelationId(input.correlationId), + now: this.now().toISOString(), + }); + } + + async current(binding: LogicalAgentBinding): Promise { + return this.store.findCurrent(normalizeBinding(binding)); + } + + async issueGrant(input: IssueConnectorExecutionGrantInput): Promise { + 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( + grant: ConnectorExecutionGrant, + requiredScope: string, + input: TInput, + adapter: FencedConnectorAdapter, + ): Promise { + 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 { + 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 { + return this.reject(grant, grant.correlationId, now, reason); + } + + private async rejectForgedGrant(grant: unknown, now: Date): Promise { + 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 { + 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}`; +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 0123793..3eabafb 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -5,3 +5,4 @@ export * from './tmux-fleet-runtime-provider.js'; export * from './hermes-runtime-provider.js'; export * from './matrix-native-runtime-provider.js'; export * from './durable-session.js'; +export * from './connector-lease.js'; diff --git a/packages/db/drizzle/0016_salty_morlocks.sql b/packages/db/drizzle/0016_salty_morlocks.sql new file mode 100644 index 0000000..fe8cecd --- /dev/null +++ b/packages/db/drizzle/0016_salty_morlocks.sql @@ -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"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0016_snapshot.json b/packages/db/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..064efdf --- /dev/null +++ b/packages/db/drizzle/meta/0016_snapshot.json @@ -0,0 +1,4530 @@ +{ + "id": "77193fb2-b6e8-4a59-b611-c37441b49e1b", + "prevId": "1a0a53b1-2dd8-4ea9-8d59-3e92ccca6c6a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_lease_audit_log": { + "name": "connector_lease_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connector_lease_audit_binding_occurred_idx": { + "name": "connector_lease_audit_binding_occurred_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "logical_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connector_lease_audit_correlation_idx": { + "name": "connector_lease_audit_correlation_idx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_checkpoints_session_idempotency_idx": { + "name": "interaction_checkpoints_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_checkpoints_session_epoch_idx": { + "name": "interaction_checkpoints_session_epoch_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compaction_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.logical_agent_connector_leases": { + "name": "logical_agent_connector_leases", + "schema": "", + "columns": { + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "logical_agent_connector_lease_binding_idx": { + "name": "logical_agent_connector_lease_binding_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "logical_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "logical_agent_connector_lease_expiry_idx": { + "name": "logical_agent_connector_lease_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "logical_agent_connector_lease_connector_idx": { + "name": "logical_agent_connector_lease_connector_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index dcdb944..1f143f9 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1783942610000, "tag": "0015_interaction_checkpoint_payload_digest", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1784050648841, + "tag": "0016_salty_morlocks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 76809d2..9735cb2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -15,6 +15,7 @@ import { uniqueIndex, real, integer, + bigint, customType, } from 'drizzle-orm/pg-core'; @@ -487,6 +488,68 @@ export const agentLogs = pgTable( ], ); +// ─── Logical agent connector authority ────────────────────────────────────── +// One durable row is the current authority for a tenant/logical-agent/binding. +// Runtime-native session identifiers never enter these core tables. + +export const logicalAgentConnectorLeases = pgTable( + 'logical_agent_connector_leases', + { + leaseId: uuid('lease_id').primaryKey(), + tenantId: text('tenant_id').notNull(), + logicalAgentId: text('logical_agent_id').notNull(), + bindingId: text('binding_id').notNull(), + connectorId: text('connector_id').notNull(), + scopes: jsonb('scopes').notNull().$type(), + leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }).notNull(), + acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull(), + heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }).notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + releasedAt: timestamp('released_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('logical_agent_connector_lease_binding_idx').on( + t.tenantId, + t.logicalAgentId, + t.bindingId, + ), + index('logical_agent_connector_lease_expiry_idx').on(t.expiresAt), + index('logical_agent_connector_lease_connector_idx').on(t.connectorId), + ], +); + +/** Append-only, credential-safe lease lifecycle and fencing denial metadata. */ +export const connectorLeaseAuditLog = pgTable( + 'connector_lease_audit_log', + { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: text('tenant_id').notNull(), + logicalAgentId: text('logical_agent_id').notNull(), + bindingId: text('binding_id').notNull(), + connectorId: text('connector_id').notNull(), + leaseId: uuid('lease_id'), + leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }), + event: text('event', { + enum: ['acquire', 'renew', 'takeover', 'reject', 'release', 'expiry'], + }).notNull(), + outcome: text('outcome', { enum: ['succeeded', 'denied'] }).notNull(), + reason: text('reason'), + correlationId: text('correlation_id').notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(), + }, + (t) => [ + index('connector_lease_audit_binding_occurred_idx').on( + t.tenantId, + t.logicalAgentId, + t.bindingId, + t.occurredAt.desc(), + ), + index('connector_lease_audit_correlation_idx').on(t.correlationId), + ], +); + // ─── Tess durable session state ───────────────────────────────────────────── // PostgreSQL is canonical for restart-safe Tess session recovery. The state // machine lives in @mosaicstack/agent; these records are its durable adapter. diff --git a/packages/types/src/agent/connector-lease.dto.spec.ts b/packages/types/src/agent/connector-lease.dto.spec.ts new file mode 100644 index 0000000..727b044 --- /dev/null +++ b/packages/types/src/agent/connector-lease.dto.spec.ts @@ -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'); + }); +}); diff --git a/packages/types/src/agent/connector-lease.dto.ts b/packages/types/src/agent/connector-lease.dto.ts new file mode 100644 index 0000000..fc5aed6 --- /dev/null +++ b/packages/types/src/agent/connector-lease.dto.ts @@ -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 { + execute(input: TInput, context: ConnectorExecutionContext): Promise; +} + +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; + takeover(input: ConnectorLeaseTakeoverMutation): Promise; + heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise; + release(input: ConnectorLeaseReleaseMutation): Promise; + findCurrent(binding: LogicalAgentBinding): Promise; + recordAudit(event: ConnectorLeaseAuditEvent): Promise; +} + +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; +} diff --git a/packages/types/src/agent/index.ts b/packages/types/src/agent/index.ts index 1803cd3..d701b4c 100644 --- a/packages/types/src/agent/index.ts +++ b/packages/types/src/agent/index.ts @@ -4,3 +4,4 @@ export interface AgentSessionHandle { } export * from './agent-runtime-provider.js'; +export * from './connector-lease.dto.js';