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
10 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 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— extendsOperationSpecwith runtimehandlerOperationContext— metadata, requestId, parentRequestId, identity, envAccessControl— requiredScopes (all match), requiredScopesAny (any match), resourceType, resourceAction. See below.ResponseEnvelope<T>— universal result wrapper with source tracking (local/http/mcp). Allexecute()andenvfunctions returnResponseEnvelope<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()acceptsOperationSpec & { handler? }(handler can be registered separately)registerSpec()/registerHandler()— separate spec and handler registrationregisterAll(definitions)— bulk registrationexecute()returnsPromise<ResponseEnvelope<TOutput>>(notPromise<TOutput>)- Constructor accepts optional
SchemaAdapterfor 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 specsget(name)/getByName(namespace, name)— retrieve definitionsgetHandler(name)— retrieve handler functionlist()— list all registered operation names
Scanner (operations/scanner.ts)
- Recursive filesystem scan for
.tsoperation definitions scanOperations(dirPath, fs)— takes an abstractedScannerFSinterface, notDeno.readDirdirectlyScannerFS { readdir(path): AsyncIterable, cwd(): string }— inject Deno or Node adapter- Auto-discovery and registration
- Validates against
OperationSpecSchema, notOperationDefinition
Env Builder (operations/env.ts)
buildEnv()creates namespace-keyedOperationEnvfor nested calls- Direct mode:
buildEnv({ registry, context })→ env functions callregistry.execute()directly buildEnvno longer takes acallMapparameter- Sets
trusted: trueon nested context (bypasses access control for internal calls) - Env functions return
Promise<ResponseEnvelope>, callers useunwrap(envelope)orenvelope.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
TSchemaconverter - 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:
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-streamresponses 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)
createMCPClientconnects to MCP servers (stdio or HTTP)- MCP tools →
IOperationDefinition[]with auto-generated handlers MCPClientLoadermanages 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>:
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 callregistry.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 for API key management. keypal verifies bearer tokens and provides a two-tier scope model:
- Global scopes: flat string array (e.g.,
["read", "write", "admin"]) - 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:
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.tsis a stub that only logs the["logtape", "meta"]category. Needs proper config for app-level loggers. - Config:
core/config/types.tshas spoke-only config. Needs hub-specific config (postgres, redis, auth).