Systematically compared @alkdev/taskgraph, @alkdev/operations, and
@alkdev/flowgraph against storage/arch specs and fixed all mismatches.
Key changes:
Tasks (storage/tasks.md + ADR-011):
- Rename TaskFrontmatter → TaskInput to match library export
- Fix dependsOn (was depends_on) in field mappings — library uses
camelCase; parseFrontmatter normalizes YAML snake_case on input
- Document DependencyEdge shape {from, to, qualityRetention?} and
DB↔library field mapping
- Document graph node vs DB column distinction (TaskGraphNodeAttrs
is a subset of TaskInput)
- Fix default risk fallback from low → medium (matches resolveDefaults)
- Fix cross-project guard column references (dependentTaskId, not taskId)
- Clarify @alkdev/taskgraph TS is source of truth; frontmatter is for
LLM output parsing and legacy imports, not Rust CLI
- Add complete library exports reference
Operations (storage/spokes.md + operations.md):
- Add version, title, _meta columns to operations table (required by
OperationSpec, were missing)
- Fix type casing: query/mutation/subscription (lowercase, matching
OperationType runtime values)
- Make outputSchema and accessControl NOT NULL (matching library)
- Document ErrorDefinition shape {code, description, schema, httpStatus?}
- Document _meta vs commonCols.metadata distinction
- Add registerAll, get, getHandler, getByName, list, subscribe methods
- Fix buildCallHandler signature ({ registry, callMap })
- Fix OperationType values (lowercase)
Call graph (storage/call-graph.md + call-graph.md):
- Change operationId to NOT NULL with RESTRICT FK (was nullable/SET NULL)
— matches flowgraph's required CallNodeAttrs.operationId
- Document sentinel __removed__ operation strategy for deletions
- Document ISO 8601 string ↔ timestamptz conversion requirement
- Rewrite CallEventMap to match actual library: flat dot-notation keys,
timestamp on all events, nested error structure, optional output on
completed event
- Remove call.running event (doesn't exist in library) — hub calls
updateStatus(running) directly on dispatch
- Fix buildCallHandler({ registry, callMap }) signature
- Fix PendingRequestMap constructor (positional EventTarget)
- Add updateCall/removeCall/graph methods to API summary
- Document abort cascade as hub logic, not flowgraph logic
- Add open questions for operation deletion and reactive vs call graph
semantics
Table reference (storage/table-reference.md):
- Update call_graph_nodes.operationId cascade to RESTRICT
- Update operations.type comment to lowercase
- Update status enum reference
198 lines
10 KiB
Markdown
198 lines
10 KiB
Markdown
---
|
|
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<T>` — universal result wrapper with source tracking (local/http/mcp). All `execute()` and `env` functions return `ResponseEnvelope<T>`.
|
|
- `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<ResponseEnvelope<TOutput>>` (not `Promise<TOutput>`)
|
|
- 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<ResponseEnvelope>`, 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<T>`:
|
|
|
|
```ts
|
|
interface ResponseEnvelope<T> {
|
|
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<string, string[]>` 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<string, string[]> // 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).
|