From 7936e15d3ae137c91c88efdab4bb09b863a2195d Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 13 Jul 2026 06:24:57 -0500 Subject: [PATCH] feat(tess): add Mos coordination boundary --- apps/gateway/src/coord/coord.module.ts | 24 +- .../gateway/src/coord/mos-coordination.dto.ts | 25 ++ .../coord/mos-coordination.service.test.ts | 217 +++++++++++++ .../src/coord/mos-coordination.service.ts | 300 ++++++++++++++++++ .../tess-m4-001-mos-coordination.md | 45 +++ docs/tess/ARCHITECTURE.md | 15 + docs/tess/MOS-COORDINATION.md | 83 +++++ docs/tess/VERIFICATION-MATRIX.md | 26 +- .../src/__tests__/mos-coordination.test.ts | 154 +++++++++ .../src/in-memory-mos-coordination-port.ts | 208 ++++++++++++ packages/coord/src/index.ts | 17 + packages/coord/src/mos-coordination.ts | 207 ++++++++++++ 12 files changed, 1306 insertions(+), 15 deletions(-) create mode 100644 apps/gateway/src/coord/mos-coordination.dto.ts create mode 100644 apps/gateway/src/coord/mos-coordination.service.test.ts create mode 100644 apps/gateway/src/coord/mos-coordination.service.ts create mode 100644 docs/scratchpads/tess-m4-001-mos-coordination.md create mode 100644 docs/tess/MOS-COORDINATION.md create mode 100644 packages/coord/src/__tests__/mos-coordination.test.ts create mode 100644 packages/coord/src/in-memory-mos-coordination-port.ts create mode 100644 packages/coord/src/mos-coordination.ts diff --git a/apps/gateway/src/coord/coord.module.ts b/apps/gateway/src/coord/coord.module.ts index d2f46e3..774cf13 100644 --- a/apps/gateway/src/coord/coord.module.ts +++ b/apps/gateway/src/coord/coord.module.ts @@ -1,10 +1,30 @@ import { Module } from '@nestjs/common'; +import { InMemoryMosCoordinationPort } from '@mosaicstack/coord'; import { CoordService } from './coord.service.js'; import { CoordController } from './coord.controller.js'; +import { + MOS_COORDINATION_CONFIG, + MOS_COORDINATION_PORT, + MosCoordinationService, +} from './mos-coordination.service.js'; @Module({ - providers: [CoordService], + providers: [ + CoordService, + { + provide: MOS_COORDINATION_PORT, + useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(), + }, + { + provide: MOS_COORDINATION_CONFIG, + useFactory: () => ({ + interactionAgentId: process.env['MOSAIC_AGENT_NAME'], + orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'], + }), + }, + MosCoordinationService, + ], controllers: [CoordController], - exports: [CoordService], + exports: [CoordService, MosCoordinationService], }) export class CoordModule {} diff --git a/apps/gateway/src/coord/mos-coordination.dto.ts b/apps/gateway/src/coord/mos-coordination.dto.ts new file mode 100644 index 0000000..7967753 --- /dev/null +++ b/apps/gateway/src/coord/mos-coordination.dto.ts @@ -0,0 +1,25 @@ +import type { + CoordinationObservation, + CoordinationResult, + MosHandoffReceipt, +} from '@mosaicstack/coord'; + +/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */ +export interface CreateMosHandoffDto { + idempotencyKey: string; + summary: string; + context?: string; + missionId?: string; +} + +export interface MosCoordinationResponseDto { + receipt: MosHandoffReceipt; +} + +export interface MosCoordinationObservationDto { + observation: CoordinationObservation; +} + +export interface MosCoordinationResultDto { + result: CoordinationResult; +} diff --git a/apps/gateway/src/coord/mos-coordination.service.test.ts b/apps/gateway/src/coord/mos-coordination.service.test.ts new file mode 100644 index 0000000..51b2a26 --- /dev/null +++ b/apps/gateway/src/coord/mos-coordination.service.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + InMemoryMosCoordinationPort, + type MosCoordinationPort, + type MosHandoff, +} from '@mosaicstack/coord'; +import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; +import { + MosCoordinationService, + type MosCoordinationConfig, + type MosCoordinationGatewayError, +} from './mos-coordination.service.js'; + +const context: RuntimeProviderRequestContext = { + actorScope: { userId: 'operator-1', tenantId: 'tenant-a' }, + channelId: 'cli', + correlationId: 'corr-1', +}; + +const config: MosCoordinationConfig = { + interactionAgentId: 'Nova', + orchestrationAgentId: 'Conductor', +}; + +function service( + port: MosCoordinationPort = new InMemoryMosCoordinationPort(), + options: { + config?: MosCoordinationConfig; + handoffIdFactory?: () => string; + } = {}, +): MosCoordinationService { + return new MosCoordinationService( + port, + options.config ?? config, + options.handoffIdFactory ?? (() => 'handoff-1'), + ); +} + +describe('MosCoordinationService authority boundary', (): void => { + it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const coordination = service(adapter); + + await expect( + coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).resolves.toEqual({ + handoffId: 'handoff-1', + targetAgentId: 'Conductor', + status: 'queued', + correlationId: 'corr-1', + }); + + adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request'); + adapter.recordResult('handoff-1', 'completed', 'Merged by Mos'); + + const followUpContext = { ...context, correlationId: 'corr-2' }; + await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({ + targetAgentId: 'Conductor', + status: 'completed', + }); + await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({ + targetAgentId: 'Conductor', + status: 'completed', + summary: 'Merged by Mos', + }); + }); + + it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const handoff = vi.spyOn(adapter, 'handoff'); + const coordination = service(adapter, { + config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' }, + }); + + await expect( + coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).rejects.toMatchObject({ + code: 'unconfigured_requester', + } satisfies Partial); + expect(handoff).not.toHaveBeenCalled(); + }); + + it('rejects self-delegation configuration before delivering work', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const handoff = vi.spyOn(adapter, 'handoff'); + const coordination = service(adapter, { + config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' }, + }); + + await expect( + coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).rejects.toThrow('Interaction and orchestration identities must differ'); + expect(handoff).not.toHaveBeenCalled(); + }); + + it('denies cross-tenant observe and result before calling the adapter', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const observe = vi.spyOn(adapter, 'observe'); + const result = vi.spyOn(adapter, 'result'); + const coordination = service(adapter); + await coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ); + + const otherTenant = { + ...context, + actorScope: { ...context.actorScope, tenantId: 'tenant-b' }, + }; + await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({ + code: 'cross_tenant_forbidden', + } satisfies Partial); + await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({ + code: 'cross_tenant_forbidden', + } satisfies Partial); + expect(observe).not.toHaveBeenCalled(); + expect(result).not.toHaveBeenCalled(); + }); + + it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise => { + let handoffSequence = 0; + let release: (() => void) | undefined; + const delivered = new Promise((resolve: () => void): void => { + release = resolve; + }); + const adapter: MosCoordinationPort = { + handoff: vi.fn(async (handoff: MosHandoff) => { + await delivered; + return { + handoffId: handoff.handoffId, + targetAgentId: handoff.targetAgentId, + status: 'queued' as const, + correlationId: handoff.scope.correlationId, + }; + }), + observe: vi.fn(), + result: vi.fn(), + }; + const coordination = service(adapter, { + handoffIdFactory: (): string => `handoff-${++handoffSequence}`, + }); + const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }; + + const first = coordination.handoff(request, context); + const retry = coordination.handoff(request, context); + expect(adapter.handoff).toHaveBeenCalledTimes(1); + release?.(); + await expect(Promise.all([first, retry])).resolves.toEqual([ + expect.objectContaining({ handoffId: 'handoff-1' }), + expect.objectContaining({ handoffId: 'handoff-1' }), + ]); + + await expect( + coordination.handoff(request, { + ...context, + actorScope: { ...context.actorScope, userId: 'operator-2' }, + }), + ).resolves.toMatchObject({ handoffId: 'handoff-2' }); + expect(adapter.handoff).toHaveBeenCalledTimes(2); + }); + + it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const handoff = vi.spyOn(adapter, 'handoff'); + const coordination = service(adapter); + await coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ); + + await expect( + coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context), + ).rejects.toMatchObject({ + code: 'handoff_conflict', + } satisfies Partial); + await expect( + coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context), + ).rejects.toMatchObject({ + code: 'invalid_request', + } satisfies Partial); + await expect( + coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context), + ).rejects.toMatchObject({ + code: 'invalid_request', + } satisfies Partial); + expect(handoff).toHaveBeenCalledTimes(1); + }); + + it('fails closed when the port reports a target that drifts from configuration', async (): Promise => { + const adapter: MosCoordinationPort = { + handoff: vi.fn(async (handoff: MosHandoff) => ({ + handoffId: handoff.handoffId, + targetAgentId: 'Unexpected', + status: 'accepted' as const, + correlationId: handoff.scope.correlationId, + })), + observe: vi.fn(), + result: vi.fn(), + }; + + await expect( + service(adapter).handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).rejects.toMatchObject({ code: 'target_drift' }); + }); +}); diff --git a/apps/gateway/src/coord/mos-coordination.service.ts b/apps/gateway/src/coord/mos-coordination.service.ts new file mode 100644 index 0000000..bc4bb98 --- /dev/null +++ b/apps/gateway/src/coord/mos-coordination.service.ts @@ -0,0 +1,300 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + MosCoordinationClient, + type CoordinationObservation, + type CoordinationResult, + type CoordinationScope, + type MosCoordinationIdentity, + type MosCoordinationPort, + type MosHandoffReceipt, +} from '@mosaicstack/coord'; +import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; +import type { CreateMosHandoffDto } from './mos-coordination.dto.js'; + +export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT'); +export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG'); + +const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000; +const MAX_TRACKED_HANDOFFS = 1_000; +const MAX_IDEMPOTENCY_KEY_LENGTH = 128; +const MAX_SUMMARY_LENGTH = 2_048; +const MAX_CONTEXT_LENGTH = 8_192; +const MAX_MISSION_ID_LENGTH = 128; + +export interface MosCoordinationConfig { + interactionAgentId?: string; + orchestrationAgentId?: string; +} + +interface HandoffOwner { + actorId: string; + tenantId: string; + requesterAgentId: string; + correlationId: string; + expiresAt: number; +} + +interface NormalizedMosHandoffRequest { + idempotencyKey: string; + summary: string; + context?: string; + missionId?: string; +} + +interface TrackedHandoff { + request: NormalizedMosHandoffRequest; + receipt: Promise; + expiresAt: number; +} + +/** + * Gateway authority boundary for the interaction agent. It derives requester, + * actor, and tenant from trusted server configuration and authentication; no + * channel request can name a target or gain Mos-owned orchestration verbs. + */ +@Injectable() +export class MosCoordinationService { + private readonly owners = new Map(); + private readonly handoffsByIdempotencyKey = new Map(); + + constructor( + @Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort, + @Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig, + private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), + ) {} + + async handoff( + request: CreateMosHandoffDto, + context: RuntimeProviderRequestContext, + ): Promise { + this.pruneExpiredTracking(); + const normalized = this.normalizeRequest(request); + const scope = this.scope(context); + const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope); + const existing = this.handoffsByIdempotencyKey.get(idempotencyKey); + if (existing !== undefined) { + if (!sameRequest(existing.request, normalized)) { + throw new MosCoordinationGatewayError( + 'handoff_conflict', + 'Mos handoff idempotency key is already bound to different immutable input', + ); + } + return existing.receipt; + } + + const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope); + const tracked: TrackedHandoff = { + request: normalized, + receipt: pending, + expiresAt: this.expiresAt(), + }; + this.handoffsByIdempotencyKey.set(idempotencyKey, tracked); + this.enforceTrackingLimit(this.handoffsByIdempotencyKey); + try { + return await pending; + } catch (error: unknown) { + if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) { + this.handoffsByIdempotencyKey.delete(idempotencyKey); + } + throw error; + } + } + + async observe( + handoffId: string, + context: RuntimeProviderRequestContext, + ): Promise { + this.pruneExpiredTracking(); + const scope = this.scope(context); + const owner = this.ownerFor(handoffId, scope); + return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId }); + } + + async result( + handoffId: string, + context: RuntimeProviderRequestContext, + ): Promise { + this.pruneExpiredTracking(); + const scope = this.scope(context); + const owner = this.ownerFor(handoffId, scope); + return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId }); + } + + private async deliverHandoff( + handoffId: string, + request: NormalizedMosHandoffRequest, + scope: CoordinationScope, + ): Promise { + const receipt = await this.client((): string => handoffId).handoff(request, scope); + const owner: HandoffOwner = { + actorId: scope.actorId, + tenantId: scope.tenantId, + requesterAgentId: scope.requesterAgentId, + correlationId: scope.correlationId, + expiresAt: this.expiresAt(), + }; + const existing = this.owners.get(receipt.handoffId); + if (existing !== undefined && !sameOwner(existing, owner)) { + throw new MosCoordinationGatewayError( + 'handoff_conflict', + 'Mos handoff ID is already bound to a different authenticated scope', + ); + } + this.owners.set(receipt.handoffId, owner); + this.enforceTrackingLimit(this.owners); + return receipt; + } + + private client(handoffIdFactory?: () => string): MosCoordinationClient { + return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory); + } + + private identity(): MosCoordinationIdentity { + const interactionAgentId = this.config.interactionAgentId?.trim(); + const orchestrationAgentId = this.config.orchestrationAgentId?.trim(); + if (!interactionAgentId) { + throw new MosCoordinationGatewayError( + 'unconfigured_requester', + 'Interaction agent identity is not configured', + ); + } + if (!orchestrationAgentId) { + throw new MosCoordinationGatewayError( + 'unconfigured_target', + 'Orchestration agent identity is not configured', + ); + } + return { interactionAgentId, orchestrationAgentId }; + } + + private scope(context: RuntimeProviderRequestContext): CoordinationScope { + const identity = this.identity(); + return Object.freeze({ + actorId: context.actorScope.userId, + tenantId: context.actorScope.tenantId, + correlationId: context.correlationId, + requesterAgentId: identity.interactionAgentId, + }); + } + + private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest { + if (typeof request !== 'object' || request === null) { + throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid'); + } + const idempotencyKey = this.requiredString( + request.idempotencyKey, + 'idempotency key', + MAX_IDEMPOTENCY_KEY_LENGTH, + ); + const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH); + const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH); + const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH); + return Object.freeze({ + idempotencyKey, + summary, + ...(context === undefined ? {} : { context }), + ...(missionId === undefined ? {} : { missionId }), + }); + } + + private idempotencyKey(requestKey: string, scope: CoordinationScope): string { + return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`; + } + + private requiredString(value: unknown, field: string, maximumLength: number): string { + if (typeof value !== 'string') { + throw new MosCoordinationGatewayError( + 'invalid_request', + `Mos handoff ${field} must be a string`, + ); + } + const normalized = value.trim(); + if (normalized.length === 0 || normalized.length > maximumLength) { + throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`); + } + return normalized; + } + + private optionalString(value: unknown, field: string, maximumLength: number): string | undefined { + if (value === undefined) return undefined; + return this.requiredString(value, field, maximumLength); + } + + private expiresAt(): number { + return Date.now() + HANDOFF_TRACKING_TTL_MS; + } + + private pruneExpiredTracking(): void { + const now = Date.now(); + for (const [key, tracked] of this.handoffsByIdempotencyKey) { + if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key); + } + for (const [key, owner] of this.owners) { + if (owner.expiresAt <= now) this.owners.delete(key); + } + } + + private enforceTrackingLimit(entries: Map): void { + while (entries.size > MAX_TRACKED_HANDOFFS) { + const oldest = entries.keys().next().value; + if (typeof oldest !== 'string') return; + entries.delete(oldest); + } + } + + private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner { + const owner = this.owners.get(handoffId); + if (owner === undefined) { + throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found'); + } + if ( + owner.tenantId !== scope.tenantId || + owner.actorId !== scope.actorId || + owner.requesterAgentId !== scope.requesterAgentId + ) { + throw new MosCoordinationGatewayError( + 'cross_tenant_forbidden', + 'Mos handoff is outside the authenticated scope', + ); + } + return owner; + } +} + +export type MosCoordinationGatewayErrorCode = + | 'cross_tenant_forbidden' + | 'handoff_conflict' + | 'invalid_request' + | 'not_found' + | 'unconfigured_requester' + | 'unconfigured_target'; + +function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean { + return ( + left.actorId === right.actorId && + left.tenantId === right.tenantId && + left.requesterAgentId === right.requesterAgentId + ); +} + +function sameRequest( + left: NormalizedMosHandoffRequest, + right: NormalizedMosHandoffRequest, +): boolean { + return ( + left.idempotencyKey === right.idempotencyKey && + left.summary === right.summary && + left.context === right.context && + left.missionId === right.missionId + ); +} + +export class MosCoordinationGatewayError extends Error { + constructor( + readonly code: MosCoordinationGatewayErrorCode, + message: string, + ) { + super(message); + this.name = MosCoordinationGatewayError.name; + } +} diff --git a/docs/scratchpads/tess-m4-001-mos-coordination.md b/docs/scratchpads/tess-m4-001-mos-coordination.md new file mode 100644 index 0000000..c013130 --- /dev/null +++ b/docs/scratchpads/tess-m4-001-mos-coordination.md @@ -0,0 +1,45 @@ +# TESS-M4-001 — Mos Coordination + +- **Issue/task:** #710 / TESS-M4-001 +- **Branch/base:** `feat/tess-mos-coordination` rebased onto `origin/main` `f1c6b37b` +- **Budget assumption:** task estimate 25K; design-first and TDD, with package contract plus gateway boundary only. + +## Objective + +Implement a transport-neutral coordination contract allowing a configured interaction agent to hand off Mos-owned work, observe activity, and receive results while preventing it from gaining coding/general orchestration authority. + +## Plan + +1. Document the contract and enforcement-point sketch; request Mos's decision on the initial concrete transport. +2. Add `@mosaicstack/coord` typed handoff/observe/result contracts and denial errors. +3. Add a gateway service which derives actor/tenant/requester identity from trusted context/configuration and validates authority. +4. Add contract and gateway boundary tests for configurable identities, self-delegation, target drift, and cross-tenant read denial. +5. Run focused, cold-cache, baseline tests; independent review; PR lifecycle. + +## Design checkpoint — 2026-07-12 + +Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryMosCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task. + +## Progress checkpoint — 2026-07-13 + +- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`. +- Implemented the gateway `MosCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context. +- Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation. +- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`. + +## Verification + +- `pnpm --filter @mosaicstack/coord test` — PASS (16 tests after authority/idempotency remediation). +- `pnpm --filter @mosaicstack/coord build` — PASS. +- `pnpm --filter @mosaicstack/gateway test -- mos-coordination.service.test.ts` — PASS (7 tests after authority/idempotency remediation). +- Standalone gateway typecheck initially reported missing built workspace packages after fresh worktree setup; root validation builds the workspace graph and passed. +- `TURBO_FORCE=true pnpm typecheck` — PASS (42 tasks, 0 cached). +- `TURBO_FORCE=true pnpm lint` — PASS (23 tasks, 0 cached after one import-type remediation). +- `TURBO_FORCE=true pnpm format:check` — PASS. +- `TURBO_FORCE=true pnpm test` — PASS (42 tasks, 0 cached; expected existing integration skips only); final cold-cache revalidation follows the remediation. + +## Review checkpoint + +- Codex code review found idempotency keys needed actor scope and concurrent retries needed an in-flight reservation; both were remediated with regression coverage. +- Codex security review found whitespace-equivalent self-delegation was accepted by the exported client; identities are now normalized before invariant checks, with regression coverage. +- Re-review added immutable payload comparison for idempotency reuse, runtime string/size validation, bounded TTL/capacity tracking for gateway and native adapter state, and fresh-correlation follow-up reads; targeted tests pass (16 coord / 7 gateway). diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index 31ae36d..d268f52 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -52,6 +52,21 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa | Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently | | Provider-specific unsupported action | None | Fail closed; never emulate silently | +### Mos Coordination Boundary + +`@mosaicstack/coord` exposes only the transport-neutral `MosCoordinationPort` +verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant, +correlation, and interaction-agent identity from authenticated context plus +trusted configuration; callers never provide an orchestration target. It +rejects unconfigured identities, self-delegation, target/correlation drift, and +cross-tenant handoff reads before an adapter call. No dispatch, assignment, +review, merge, or cancellation API exists at this boundary. + +M4 uses a deterministic native in-process queue adapter to prove the handoff → +observe → result flow without coupling the contract to tmux. A fleet/tmux +adapter is deferred to the M5 live-deployment seam and must implement the same +port. + ## Session and State Model A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation. diff --git a/docs/tess/MOS-COORDINATION.md b/docs/tess/MOS-COORDINATION.md new file mode 100644 index 0000000..2eb3c81 --- /dev/null +++ b/docs/tess/MOS-COORDINATION.md @@ -0,0 +1,83 @@ +# Tess–Mos Coordination Contract Sketch + +**Task:** TESS-M4-001 · **PRD:** TESS-MOS-001 / AC-TESS-04 + +## Boundary + +Agent identities are deployment data. A configured interaction agent may request +Mos-owned work; the configured orchestration agent owns decomposition, worker +assignment, reviews, and merge decisions. The interaction agent receives a +correlated receipt, read-only activity projection, and terminal result. It has +no dispatch, assignment, review, merge, or cancellation operation. + +## `@mosaicstack/coord` interface + +```ts +interface CoordinationScope { + readonly actorId: string; + readonly tenantId: string; + readonly correlationId: string; + readonly requesterAgentId: string; // trusted gateway/configuration data +} + +interface MosHandoffRequest { + readonly idempotencyKey: string; + readonly summary: string; + readonly context?: string; + readonly missionId?: string; +} + +interface MosHandoffReceipt { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: 'accepted' | 'queued'; + readonly correlationId: string; +} + +interface MosHandoff { + readonly handoffId: string; + readonly targetAgentId: string; + readonly request: MosHandoffRequest; + readonly scope: CoordinationScope; +} + +interface MosCoordinationPort { + handoff(handoff: MosHandoff): Promise; + observe(handoffId: string, scope: CoordinationScope): Promise; + result(handoffId: string, scope: CoordinationScope): Promise; +} +``` + +The port deliberately omits generic orchestrator verbs. It is tenant- and +correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`, +and the requester agent from trusted authentication/configuration only. + +## Enforcement point + +`apps/gateway` owns a `MosCoordinationService` boundary that compares the +trusted configured requester/target identities and rejects all of the following +before calling a transport: unconfigured requester, self-delegation, target +identity drift, cross-tenant observe/result lookup, and attempts to observe or +receive a result for a handoff outside the originating tenant. The service exposes handoff, observe, +and result only, and delegates delivery to an injected adapter. + +M4 ships a native in-process `InMemoryMosCoordinationPort` as the concrete, +deterministic adapter. It preserves the immutable handoff ID, tenant, requester +identity, and correlation ID while demonstrating the handoff → observe → result +round trip. It is a queue/port adapter, not a Mos-side consumer. + +A future fleet/tmux adapter is a documented M5 deployment seam and must +implement the same `MosCoordinationPort`; no channel client or interaction +runtime calls a transport directly. + +## Required tests + +1. A configured non-default interaction identity can hand off work to a + configured non-default orchestration identity and receive its result. +2. The gateway passes only server-derived scope/identity to the adapter. +3. Self-targeting, target drift, and cross-tenant observe/result all fail closed + without invoking the adapter. +4. The exported public contract has no worker-dispatch, assignment, review, + merge, or cancellation capability. +5. The native adapter round-trips queued work, activity, and a host-recorded + terminal result without a live fleet dependency. diff --git a/docs/tess/VERIFICATION-MATRIX.md b/docs/tess/VERIFICATION-MATRIX.md index 82864aa..472ca7d 100644 --- a/docs/tess/VERIFICATION-MATRIX.md +++ b/docs/tess/VERIFICATION-MATRIX.md @@ -1,18 +1,18 @@ # Tess Verification Matrix -| Acceptance criterion | Requirements | Planned evidence | Gate | -| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | -| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | -| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | -| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V | -| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | -| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | -| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | -| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | -| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | -| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | -| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | +| Acceptance criterion | Requirements | Planned evidence | Gate | +| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | +| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | +| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | +| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | M4 contract/gateway native-port handoff → observe → result round trip; configurable identity, target-drift and tenant-denial tests; M4-V fleet authority qualification | M4-001, M4-V | +| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | +| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | +| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | +| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | +| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | +| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | +| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | ## Security Abuse Suite Minimum diff --git a/packages/coord/src/__tests__/mos-coordination.test.ts b/packages/coord/src/__tests__/mos-coordination.test.ts new file mode 100644 index 0000000..912bdc6 --- /dev/null +++ b/packages/coord/src/__tests__/mos-coordination.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + InMemoryMosCoordinationPort, + MosCoordinationClient, + type CoordinationScope, + type MosCoordinationAuthorityError, + type MosCoordinationPort, +} from '../index.js'; + +const scope: CoordinationScope = { + actorId: 'operator-1', + tenantId: 'tenant-a', + correlationId: 'corr-1', + requesterAgentId: 'Nova', +}; + +function client( + port: MosCoordinationPort, + handoffIdFactory: () => string = (): string => 'handoff-1', +): MosCoordinationClient { + return new MosCoordinationClient( + { interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' }, + port, + handoffIdFactory, + ); +} + +describe('MosCoordinationClient', (): void => { + it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const coordination = client(adapter); + + await expect( + coordination.handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + scope, + ), + ).resolves.toEqual({ + handoffId: 'handoff-1', + targetAgentId: 'Conductor', + status: 'queued', + correlationId: 'corr-1', + }); + + adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request'); + adapter.recordResult('handoff-1', 'completed', 'Merged by Mos'); + + await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({ + status: 'completed', + targetAgentId: 'Conductor', + activity: expect.arrayContaining([ + expect.objectContaining({ status: 'queued' }), + expect.objectContaining({ status: 'running' }), + expect.objectContaining({ status: 'completed' }), + ]), + }); + await expect(coordination.result('handoff-1', scope)).resolves.toEqual({ + handoffId: 'handoff-1', + targetAgentId: 'Conductor', + status: 'completed', + correlationId: 'corr-1', + summary: 'Merged by Mos', + }); + + expect(coordination).not.toHaveProperty('dispatch'); + expect(coordination).not.toHaveProperty('assign'); + expect(coordination).not.toHaveProperty('review'); + expect(coordination).not.toHaveProperty('merge'); + expect(coordination).not.toHaveProperty('cancel'); + }); + + it('fails closed before delivery when an unconfigured agent requests Mos work', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + + await expect( + client(adapter).handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + { ...scope, requesterAgentId: 'Untrusted' }, + ), + ).rejects.toMatchObject({ + code: 'requester_forbidden', + } satisfies Partial); + }); + + it('rejects self-delegation configuration before constructing a client', (): void => { + expect( + (): MosCoordinationClient => + new MosCoordinationClient( + { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' }, + new InMemoryMosCoordinationPort(), + ), + ).toThrow('Interaction and orchestration identities must differ'); + }); + + it('rejects whitespace-equivalent self-delegation identities', (): void => { + expect( + (): MosCoordinationClient => + new MosCoordinationClient( + { interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' }, + new InMemoryMosCoordinationPort(), + ), + ).toThrow('Interaction and orchestration identities must differ'); + }); + + it('does not expose another tenant handoff to observe or result', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const coordination = client(adapter); + await coordination.handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + scope, + ); + + const otherTenantScope = { ...scope, tenantId: 'tenant-b' }; + await expect(coordination.observe('handoff-1', otherTenantScope)).rejects.toMatchObject({ + code: 'forbidden', + }); + await expect(coordination.result('handoff-1', otherTenantScope)).rejects.toMatchObject({ + code: 'forbidden', + }); + }); + + it('bounds native handoff retention by evicting the oldest handoff', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort({ maxHandoffs: 1 }); + const first = client(adapter, (): string => 'handoff-1'); + const second = client(adapter, (): string => 'handoff-2'); + await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope); + await second.handoff({ idempotencyKey: 'handoff-request-2', summary: 'Second request' }, scope); + + await expect(first.observe('handoff-1', scope)).rejects.toMatchObject({ code: 'not_found' }); + await expect(second.observe('handoff-2', scope)).resolves.toMatchObject({ status: 'queued' }); + }); + + it('fails closed when a transport reports target drift', async (): Promise => { + const adapter: MosCoordinationPort = { + handoff: vi.fn(async () => ({ + handoffId: 'handoff-1', + targetAgentId: 'Unexpected', + status: 'accepted' as const, + correlationId: 'corr-1', + })), + observe: vi.fn(), + result: vi.fn(), + }; + + await expect( + client(adapter).handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + scope, + ), + ).rejects.toMatchObject({ + code: 'target_drift', + } satisfies Partial); + }); +}); diff --git a/packages/coord/src/in-memory-mos-coordination-port.ts b/packages/coord/src/in-memory-mos-coordination-port.ts new file mode 100644 index 0000000..ddebbad --- /dev/null +++ b/packages/coord/src/in-memory-mos-coordination-port.ts @@ -0,0 +1,208 @@ +import { + type CoordinationObservation, + type CoordinationResult, + type CoordinationScope, + type MosCoordinationActivity, + type MosCoordinationPort, + type MosHandoff, + type MosHandoffReceipt, + type MosHandoffStatus, +} from './mos-coordination.js'; + +const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000; +const DEFAULT_MAX_HANDOFFS = 1_000; + +interface StoredHandoff { + readonly handoff: MosHandoff; + status: MosHandoffStatus; + readonly activity: MosCoordinationActivity[]; + readonly expiresAt: number; + result?: CoordinationResult; +} + +export interface InMemoryMosCoordinationPortOptions { + now?: () => Date; + handoffTtlMs?: number; + maxHandoffs?: number; +} + +/** + * Native deterministic queue/port adapter for the coordination boundary. + * It intentionally has no fleet/tmux dependency. A future deployment adapter + * implements MosCoordinationPort without changing interaction-plane callers. + */ +export class InMemoryMosCoordinationPort implements MosCoordinationPort { + private readonly handoffs = new Map(); + private readonly now: () => Date; + private readonly handoffTtlMs: number; + private readonly maxHandoffs: number; + + constructor(options: InMemoryMosCoordinationPortOptions = {}) { + this.now = options.now ?? (() => new Date()); + this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS; + this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS; + } + + async handoff(handoff: MosHandoff): Promise { + this.pruneExpiredHandoffs(); + const existing = this.handoffs.get(handoff.handoffId); + if (existing !== undefined) { + this.assertSameHandoff(existing.handoff, handoff); + return this.receipt(existing.handoff, existing.status); + } + + const stored: StoredHandoff = { + handoff: snapshotHandoff(handoff), + status: 'queued', + activity: [activity('queued', 'Handoff accepted by the native coordination queue', this.now)], + expiresAt: this.now().getTime() + this.handoffTtlMs, + }; + this.handoffs.set(handoff.handoffId, stored); + this.enforceHandoffLimit(); + return this.receipt(stored.handoff, stored.status); + } + + async observe(handoffId: string, scope: CoordinationScope): Promise { + this.pruneExpiredHandoffs(); + const stored = this.requireScopedHandoff(handoffId, scope); + return { + handoffId: stored.handoff.handoffId, + targetAgentId: stored.handoff.targetAgentId, + status: stored.status, + correlationId: stored.handoff.scope.correlationId, + activity: stored.activity.map(copyActivity), + }; + } + + async result(handoffId: string, scope: CoordinationScope): Promise { + this.pruneExpiredHandoffs(); + const stored = this.requireScopedHandoff(handoffId, scope); + return ( + stored.result ?? { + handoffId: stored.handoff.handoffId, + targetAgentId: stored.handoff.targetAgentId, + status: 'pending', + correlationId: stored.handoff.scope.correlationId, + } + ); + } + + /** Host-side progression seam; interaction clients never receive this capability. */ + recordActivity(handoffId: string, status: MosHandoffStatus, summary: string): void { + this.pruneExpiredHandoffs(); + const stored = this.requireHandoff(handoffId); + stored.status = status; + stored.activity.push(activity(status, summary, this.now)); + } + + /** Host-side result seam for deterministic qualification; not a Mos consumer. */ + recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void { + this.pruneExpiredHandoffs(); + const stored = this.requireHandoff(handoffId); + stored.status = status; + stored.activity.push(activity(status, summary, this.now)); + stored.result = { + handoffId: stored.handoff.handoffId, + targetAgentId: stored.handoff.targetAgentId, + status, + correlationId: stored.handoff.scope.correlationId, + summary, + }; + } + + private receipt(handoff: MosHandoff, status: MosHandoffStatus): MosHandoffReceipt { + return { + handoffId: handoff.handoffId, + targetAgentId: handoff.targetAgentId, + status: status === 'accepted' ? 'accepted' : 'queued', + correlationId: handoff.scope.correlationId, + }; + } + + private pruneExpiredHandoffs(): void { + const nowMs = this.now().getTime(); + for (const [handoffId, handoff] of this.handoffs) { + if (handoff.expiresAt <= nowMs) this.handoffs.delete(handoffId); + } + } + + private enforceHandoffLimit(): void { + while (this.handoffs.size > this.maxHandoffs) { + const oldest = this.handoffs.keys().next().value; + if (typeof oldest !== 'string') return; + this.handoffs.delete(oldest); + } + } + + private requireScopedHandoff(handoffId: string, scope: CoordinationScope): StoredHandoff { + const stored = this.requireHandoff(handoffId); + if ( + stored.handoff.scope.tenantId !== scope.tenantId || + stored.handoff.scope.actorId !== scope.actorId || + stored.handoff.scope.requesterAgentId !== scope.requesterAgentId + ) { + throw new InMemoryMosCoordinationError('forbidden', 'Handoff scope does not match'); + } + return stored; + } + + private requireHandoff(handoffId: string): StoredHandoff { + const stored = this.handoffs.get(handoffId); + if (stored === undefined) { + throw new InMemoryMosCoordinationError('not_found', 'Handoff was not found'); + } + return stored; + } + + private assertSameHandoff(existing: MosHandoff, incoming: MosHandoff): void { + if ( + existing.targetAgentId !== incoming.targetAgentId || + existing.request.idempotencyKey !== incoming.request.idempotencyKey || + existing.request.summary !== incoming.request.summary || + existing.request.context !== incoming.request.context || + existing.request.missionId !== incoming.request.missionId || + existing.scope.actorId !== incoming.scope.actorId || + existing.scope.tenantId !== incoming.scope.tenantId || + existing.scope.correlationId !== incoming.scope.correlationId || + existing.scope.requesterAgentId !== incoming.scope.requesterAgentId + ) { + throw new InMemoryMosCoordinationError( + 'conflict', + 'Handoff ID is already bound to different immutable input', + ); + } + } +} + +export type InMemoryMosCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found'; + +export class InMemoryMosCoordinationError extends Error { + constructor( + readonly code: InMemoryMosCoordinationErrorCode, + message: string, + ) { + super(message); + this.name = InMemoryMosCoordinationError.name; + } +} + +function activity( + status: MosHandoffStatus, + summary: string, + now: () => Date, +): MosCoordinationActivity { + return { occurredAt: now().toISOString(), status, summary }; +} + +function copyActivity(entry: MosCoordinationActivity): MosCoordinationActivity { + return { ...entry }; +} + +function snapshotHandoff(handoff: MosHandoff): MosHandoff { + return Object.freeze({ + handoffId: handoff.handoffId, + targetAgentId: handoff.targetAgentId, + request: Object.freeze({ ...handoff.request }), + scope: Object.freeze({ ...handoff.scope }), + }); +} diff --git a/packages/coord/src/index.ts b/packages/coord/src/index.ts index 7ac5309..cf59bac 100644 --- a/packages/coord/src/index.ts +++ b/packages/coord/src/index.ts @@ -2,6 +2,23 @@ export { createMission, loadMission, missionFilePath, saveMission } from './miss export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js'; export { runTask, resumeTask } from './runner.js'; export { getMissionStatus, getTaskStatus } from './status.js'; +export { + InMemoryMosCoordinationError, + InMemoryMosCoordinationPort, +} from './in-memory-mos-coordination-port.js'; +export { MosCoordinationAuthorityError, MosCoordinationClient } from './mos-coordination.js'; +export type { + CoordinationObservation, + CoordinationResult, + CoordinationScope, + MosCoordinationActivity, + MosCoordinationIdentity, + MosCoordinationPort, + MosHandoff, + MosHandoffReceipt, + MosHandoffRequest, + MosHandoffStatus, +} from './mos-coordination.js'; export type { CreateMissionOptions, Mission, diff --git a/packages/coord/src/mos-coordination.ts b/packages/coord/src/mos-coordination.ts new file mode 100644 index 0000000..f71d2ea --- /dev/null +++ b/packages/coord/src/mos-coordination.ts @@ -0,0 +1,207 @@ +export type MosHandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed'; + +export interface CoordinationScope { + readonly actorId: string; + readonly tenantId: string; + readonly correlationId: string; + /** Trusted gateway/configuration identity; never supplied by a channel client. */ + readonly requesterAgentId: string; +} + +export interface MosCoordinationIdentity { + readonly interactionAgentId: string; + readonly orchestrationAgentId: string; +} + +export interface MosHandoffRequest { + readonly idempotencyKey: string; + readonly summary: string; + readonly context?: string; + readonly missionId?: string; +} + +export interface MosHandoff { + readonly handoffId: string; + readonly targetAgentId: string; + readonly request: MosHandoffRequest; + readonly scope: CoordinationScope; +} + +export interface MosHandoffReceipt { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: 'queued' | 'accepted'; + readonly correlationId: string; +} + +export interface MosCoordinationActivity { + readonly occurredAt: string; + readonly status: MosHandoffStatus; + readonly summary: string; +} + +export interface CoordinationObservation { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: MosHandoffStatus; + readonly correlationId: string; + readonly activity: readonly MosCoordinationActivity[]; +} + +export interface CoordinationResult { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: 'completed' | 'failed' | 'pending'; + readonly correlationId: string; + readonly summary?: string; +} + +/** + * Transport-neutral boundary. The interaction plane can request work and read + * its progress/result, but it cannot issue worker, review, merge, or other + * general orchestration commands. + */ +export interface MosCoordinationPort { + handoff(handoff: MosHandoff): Promise; + observe(handoffId: string, scope: CoordinationScope): Promise; + result(handoffId: string, scope: CoordinationScope): Promise; +} + +export type MosCoordinationAuthorityErrorCode = + | 'invalid_identity' + | 'requester_forbidden' + | 'target_drift' + | 'correlation_drift'; + +export class MosCoordinationAuthorityError extends Error { + constructor( + readonly code: MosCoordinationAuthorityErrorCode, + message: string, + ) { + super(message); + this.name = MosCoordinationAuthorityError.name; + } +} + +/** + * Enforces the interaction-to-orchestration authority boundary before a + * transport is reached. Identity names remain configuration data. + */ +export class MosCoordinationClient { + private readonly identity: MosCoordinationIdentity; + + constructor( + identity: MosCoordinationIdentity, + private readonly port: MosCoordinationPort, + private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), + ) { + this.identity = normalizeIdentity(identity); + } + + async handoff(request: MosHandoffRequest, scope: CoordinationScope): Promise { + this.assertRequester(scope); + const handoff: MosHandoff = { + handoffId: this.handoffIdFactory(), + targetAgentId: this.identity.orchestrationAgentId, + request: snapshotRequest(request), + scope: snapshotScope(scope), + }; + const receipt = await this.port.handoff(handoff); + if ( + receipt.handoffId !== handoff.handoffId || + receipt.targetAgentId !== handoff.targetAgentId + ) { + throw new MosCoordinationAuthorityError( + 'target_drift', + 'Mos coordination transport returned a mismatched handoff target', + ); + } + if (receipt.correlationId !== handoff.scope.correlationId) { + throw new MosCoordinationAuthorityError( + 'correlation_drift', + 'Mos coordination transport returned a mismatched correlation ID', + ); + } + return receipt; + } + + async observe(handoffId: string, scope: CoordinationScope): Promise { + this.assertRequester(scope); + return this.assertObservation(await this.port.observe(handoffId, snapshotScope(scope)), scope); + } + + async result(handoffId: string, scope: CoordinationScope): Promise { + this.assertRequester(scope); + return this.assertResult(await this.port.result(handoffId, snapshotScope(scope)), scope); + } + + private assertRequester(scope: CoordinationScope): void { + if (scope.requesterAgentId !== this.identity.interactionAgentId) { + throw new MosCoordinationAuthorityError( + 'requester_forbidden', + 'Requester is not the configured interaction agent', + ); + } + } + + private assertObservation( + observation: CoordinationObservation, + scope: CoordinationScope, + ): CoordinationObservation { + if (observation.targetAgentId !== this.identity.orchestrationAgentId) { + throw new MosCoordinationAuthorityError( + 'target_drift', + 'Mos coordination transport returned an unexpected observation target', + ); + } + if (observation.correlationId !== scope.correlationId) { + throw new MosCoordinationAuthorityError( + 'correlation_drift', + 'Mos coordination transport returned a mismatched observation correlation ID', + ); + } + return observation; + } + + private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult { + if (result.targetAgentId !== this.identity.orchestrationAgentId) { + throw new MosCoordinationAuthorityError( + 'target_drift', + 'Mos coordination transport returned an unexpected result target', + ); + } + if (result.correlationId !== scope.correlationId) { + throw new MosCoordinationAuthorityError( + 'correlation_drift', + 'Mos coordination transport returned a mismatched result correlation ID', + ); + } + return result; + } +} + +function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationIdentity { + const interactionAgentId = identity.interactionAgentId.trim(); + const orchestrationAgentId = identity.orchestrationAgentId.trim(); + if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) { + throw new MosCoordinationAuthorityError( + 'invalid_identity', + 'Interaction and orchestration identities are required', + ); + } + if (interactionAgentId === orchestrationAgentId) { + throw new MosCoordinationAuthorityError( + 'invalid_identity', + 'Interaction and orchestration identities must differ', + ); + } + return Object.freeze({ interactionAgentId, orchestrationAgentId }); +} + +function snapshotRequest(request: MosHandoffRequest): MosHandoffRequest { + return Object.freeze({ ...request }); +} + +function snapshotScope(scope: CoordinationScope): CoordinationScope { + return Object.freeze({ ...scope }); +}