--- status: draft last_updated: 2026-05-25 --- # Operations System ## Overview The operations system is the universal abstraction for all work in the alk.dev platform. Every API endpoint, agent action, coordination tool, and MCP tool is an operation with typed input/output schemas, access control metadata, and a handler function. **Package**: `@alkdev/operations` (npm) ## Core Components ### Core Types (`operations/types.ts`) - `OperationType` — `QUERY = "query"`, `MUTATION = "mutation"`, `SUBSCRIPTION = "subscription"` (enum names uppercase, string values lowercase) - `OperationSpec` — serializable, hashable subset (name, namespace, version, type, description, title?, tags?, inputSchema, outputSchema, errorSchemas?, accessControl, _meta?) - `IOperationDefinition` — extends `OperationSpec` with runtime `handler` - `OperationContext` — metadata, requestId, parentRequestId, identity, env - `AccessControl` — requiredScopes (all match), requiredScopesAny (any match), resourceType, resourceAction. See below. - `ResponseEnvelope` — universal result wrapper with source tracking (local/http/mcp). All `execute()` and `env` functions return `ResponseEnvelope`. - `CallError` / `InfrastructureErrorCode` — structured error codes: `OPERATION_NOT_FOUND`, `ACCESS_DENIED`, `VALIDATION_ERROR`, `TIMEOUT`, `ABORTED`, `EXECUTION_ERROR`, `UNKNOWN_ERROR`. - `ErrorDefinition` — structured error schema declaration: `{ code: string, description: string, schema: unknown, httpStatus?: number }` ### Registry (`operations/registry.ts`) - Register by `{namespace}.{name}` key - `register()` accepts `OperationSpec & { handler? }` (handler can be registered separately) - `registerSpec()` / `registerHandler()` — separate spec and handler registration - `registerAll(definitions)` — bulk registration - `execute()` returns `Promise>` (not `Promise`) - Constructor accepts optional `SchemaAdapter` for Zod/Valibot conversion - Access control is enforced in the registry (via `enforceAccess`) - Validate input before handler execution - Warn on output schema mismatch (don't throw) - `getSpec()` / `getAllSpecs()` for serializable specs - `get(name)` / `getByName(namespace, name)` — retrieve definitions - `getHandler(name)` — retrieve handler function - `list()` — list all registered operation names ### Scanner (`operations/scanner.ts`) - Recursive filesystem scan for `.ts` operation definitions - `scanOperations(dirPath, fs)` — takes an abstracted `ScannerFS` interface, not `Deno.readDir` directly - `ScannerFS { readdir(path): AsyncIterable, cwd(): string }` — inject Deno or Node adapter - Auto-discovery and registration - Validates against `OperationSpecSchema`, not `OperationDefinition` ### Env Builder (`operations/env.ts`) - `buildEnv()` creates namespace-keyed `OperationEnv` for nested calls - Direct mode: `buildEnv({ registry, context })` → env functions call `registry.execute()` directly - `buildEnv` no longer takes a `callMap` parameter - Sets `trusted: true` on nested context (bypasses access control for internal calls) - Env functions return `Promise`, callers use `unwrap(envelope)` or `envelope.data` - Filters SUBSCRIPTION operations out of env - `subscribe(registry, operationId, input, context)` — standalone function for subscription operations ### FromSchema (`operations/from_schema.ts`) - JSON Schema → TypeBox `TSchema` converter - Handles allOf, anyOf, oneOf, enum, object, tuple, array, const, $ref, primitives ### Schema Adapters (`@alkdev/operations/from-typemap`) The `SchemaAdapter` pattern converts non-TypeBox schemas to TypeBox at registration time: ```ts import { zodAdapter, valibotAdapter } from "@alkdev/operations/from-typemap" const registry = new OperationRegistry({ schemaAdapter: zodAdapter() }) // or: { schemaAdapter: valibotAdapter() } // or: { schemaAdapter: defaultAdapter } // TypeBox only (default) ``` The `SchemaAdapter` interface has `toTypeBox(schema)` and optional `init()`. Zod and Valibot adapters use dynamic import of `@alkdev/typemap` and check for `~standard` vendor property for auto-detection. `@alkdev/typemap` is an optional peer dependency — it's only loaded when a Zod or Valibot schema is actually encountered. Spoke authors using TypeBox directly have no extra dependencies. Non-TypeScript spokes send JSON Schema over the wire, which the hub converts via `FromSchema()`. **See ADR-013** for the full decision and trade-offs. ### FromOpenAPI (`operations/from_openapi.ts`) - **Key piece**: generates `IOperationDefinition[]` from OpenAPI specs - Detects `text/event-stream` responses as SUBSCRIPTION type - Auto-generates HTTP fetch handlers with path/query/body param routing - Supports bearer, apiKey, basic auth - **Use case**: import opencode's OpenAPI spec → instant typed client operations ### MCP Wrapper (`mcp/wrapper.ts`, `mcp/loader.ts`) - `createMCPClient` connects to MCP servers (stdio or HTTP) - MCP tools → `IOperationDefinition[]` with auto-generated handlers - `MCPClientLoader` manages multiple MCP client connections - **Use case**: connect to external MCP servers (websearch, etc.) and wrap as operations ### ResponseEnvelope All `execute()` calls and `env` functions return `ResponseEnvelope`: ```ts interface ResponseEnvelope { data: T meta: ResponseMeta // source: "local" | "http" | "mcp", timestamps, status codes } ``` Factory functions: `localEnvelope(data, operationId)`, `httpEnvelope(data, meta)`, `mcpEnvelope(data, meta)`. Use `unwrap(envelope)` to extract `.data` or `isResponseEnvelope(value)` to type-guard. ### Access Control `checkAccess(accessControl, identity)` — boolean check. `enforceAccess(accessControl, identity, operationId, trusted?)` — throws `CallError` on denial. The `trusted: true` flag bypasses all access checks (set by `buildEnv` on nested calls). ### CallError `CallError` extends `Error` with `code` and `details`. `InfrastructureErrorCode` enum provides standard error codes. `mapError(error, errorSchemas?)` matches thrown errors against declared `errorSchemas`. ## Open Issues ### Call Protocol Integration Operations use `buildEnv()` which supports direct mode (see call-graph.md): - **Direct mode**: `buildEnv({ registry, context })` → env functions call `registry.execute()` The call protocol (PendingRequestMap, CallHandler) is part of `@alkdev/operations`. It provides call graph tracking, abort cascading, and structured error handling across all transports. The `buildCallHandler({ registry, callMap })` creates a `CallHandler` that subscribes to `call.requested` events on the `callMap` (a `PendingRequestMap`), enforces access control, and dispatches via `registry.execute()`. See call-graph.md for the full spec. ## How It Connects to Everything Else ``` Hub HTTP API routes ──→ registry.execute("namespace.operation", input, ctx) │ MCP server tools ──→ registry.execute(...) │ FromOpenAPI ops ──→ fetch(opencode container REST API) │ MCP client tools ──→ MCPClientLoader → registry.execute(...) │ Agent session LLM ──→ tool calls with JSON Schema → registry.execute(...) ``` All paths funnel into the same registry. Access control, validation, and error handling are consistent regardless of entry point. ## Access Control Model Authentication uses [keypal](https://npmjs.com/package/keypal) for API key management. keypal verifies bearer tokens and provides a two-tier scope model: 1. **Global scopes**: flat string array (e.g., `["read", "write", "admin"]`) 2. **Resource-scoped permissions**: `Record` keyed by `"type:id"` (e.g., `{ "project:abc": ["read", "write"] }`) ### Identity The `Identity` type derives from keypal's `ApiKeyMetadata`: ```ts interface Identity { id: string // keypal ownerId scopes: string[] // global scopes from keypal resources?: Record // resource-scoped permissions, key format: "type:id" } ``` "Roles" are scope bundles — a convention on top of scopes, not a separate type. For example, a scope of `"implement"` might grant access to `["dev.fs.read", "dev.fs.write", "dev.bash.exec"]`. Defining which scopes a "role" maps to is a configuration concern, not a type-system concern. ### AccessControl The `AccessControl` definition on each operation declares what permissions are required: | Field | Semantics | Example | |-------|-----------|---------| | `requiredScopes` | AND — caller must have ALL of these scopes | `["call"]` — caller can invoke operations | | `requiredScopesAny` | OR — caller must have at least ONE of these scopes | `["admin", "coord.spawn"]` — admin OR can spawn | | `resourceType` | Resource category for resource-scoped checks | `"project"` | | `resourceAction` | Required action on the resource | `"write"` | **Enforcement**: The `CallHandler` (see call-graph.md) checks `AccessControl` against `Identity` before dispatching to `registry.execute()`. The registry itself is a pure execution engine — access control lives at the call handler layer. **Resource checks**: When `resourceType` + `resourceAction` are set, the check is: does `identity.resources["{resourceType}:{resourceId}"]` include `resourceAction`? This maps directly to keypal's `checkResourceScope(record, resourceType, resourceId, scope)`. ### Access Control Flow ``` Request → CallHandler receives call.requested with Identity → Look up operation's AccessControl → Check requiredScopes (caller has ALL?) → Check requiredScopesAny (caller has ANY?) → Check resourceType/resourceAction against identity.resources → All pass → registry.execute() → Any fail → call.error with ACCESS_DENIED ``` ## Known Gaps - **Logger config**: `core/logger/mod.ts` is a stub that only logs the `["logtape", "meta"]` category. Needs proper config for app-level loggers. - **Config**: `core/config/types.ts` has spoke-only config. Needs hub-specific config (postgres, redis, auth).