docs: add operation context, handler environment, and adapter patterns to services.md
Document the OperationContext (request_id, identity, metadata, env, trusted), OperationEnv (namespaced callables for handler composition), ResponseEnvelope pattern, and how MCP/OpenAPI adapters map to the irpc service model.
This commit is contained in:
@@ -267,6 +267,126 @@ enum StorageProtocol {
|
|||||||
|
|
||||||
The `Subscribe` variant uses server-streaming irpc — the client sends one request and receives multiple `StorageEvent` messages via `mpsc::Sender`. These are honker stream events projected into integration events.
|
The `Subscribe` variant uses server-streaming irpc — the client sends one request and receives multiple `StorageEvent` messages via `mpsc::Sender`. These are honker stream events projected into integration events.
|
||||||
|
|
||||||
|
## Operation Context and Handler Environment
|
||||||
|
|
||||||
|
The call protocol's `OperationSpec` defines *what* an operation looks like (name, namespace, input/output schemas, access control). But the handler that actually processes the call needs more than just `input` — it needs **context**: who made the call, what other operations it can invoke, and what identity it runs as.
|
||||||
|
|
||||||
|
This is the pattern established in `@alkdev/operations` and needs to map cleanly to the Rust implementation.
|
||||||
|
|
||||||
|
### OperationContext
|
||||||
|
|
||||||
|
Every handler receives an `OperationContext` alongside its input:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct OperationContext {
|
||||||
|
pub request_id: String,
|
||||||
|
pub parent_request_id: Option<String>,
|
||||||
|
pub identity: Option<Identity>,
|
||||||
|
pub metadata: HashMap<String, Value>,
|
||||||
|
pub env: OperationEnv,
|
||||||
|
pub trusted: bool, // set by buildEnv(), not by callers
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Identity {
|
||||||
|
pub id: String,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub resources: Option<HashMap<String, Vec<String>>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key fields:
|
||||||
|
|
||||||
|
- **`request_id`** / **`parent_request_id`**: Call tracing. A mutation that triggers events carries `parent_request_id` so the call graph can link them.
|
||||||
|
- **`identity`**: The authenticated identity making the call. Populated by the auth service from the call protocol's `call.requested` event. ACL checks use `identity.scopes` and `identity.resources` via the operation's `accessControl`.
|
||||||
|
- **`metadata`**: Arbitrary key-value context. Used for things like trace IDs, correlation headers, or feature flags.
|
||||||
|
- **`env`**: The **operation environment** — namespaced access to call other operations. This is the composition mechanism.
|
||||||
|
- **`trusted`**: Internal flag set by `buildEnv()`. When a handler calls another operation through `env`, the nested call is `trusted` (skips ACL checks). This prevents handlers from having to manage auth scope escalation themselves.
|
||||||
|
|
||||||
|
### OperationEnv (the composition mechanism)
|
||||||
|
|
||||||
|
`OperationEnv` provides namespaced access to the operation registry. A handler can call other operations without knowing their transport:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type OperationEnv = HashMap<String, HashMap<String, fn(Value, OperationContext) -> ResponseEnvelope>>;
|
||||||
|
|
||||||
|
// Usage inside a handler:
|
||||||
|
let result = context.env["secrets"]["deriveKey"](derive_input, nested_context)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
In TypeScript, `buildEnv()` iterates all registered specs (excluding subscriptions), creates closure functions for each, and passes `trusted: true` in the nested context. The Rust equivalent uses irpc service calls:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Local: direct function call through handler map
|
||||||
|
// Remote: irpc call to the service that owns that operation
|
||||||
|
```
|
||||||
|
|
||||||
|
This means a handler for `/head/docker/create` can internally call `/head/secrets/derive` to get a key for the container, and the nested call is routed through the same service layer — locally if the secret service is on the same node, remotely via irpc if it's on a different node.
|
||||||
|
|
||||||
|
### Mapping to irpc
|
||||||
|
|
||||||
|
The TypeScript `OperationEnv` pattern maps to irpc as follows:
|
||||||
|
|
||||||
|
| TypeScript | Rust (irpc) |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `context.env.namespace.op(input)` | `client.rpc(ProtocolMessage::OpName { ... }).await?` |
|
||||||
|
| `buildEnv(registry, context)` | `irpc::Client::local(tx)` or `irpc::Client::remote(conn)` |
|
||||||
|
| `registry.execute(id, input, context)` | Service handler dispatch on the enum variant |
|
||||||
|
| `accessControl` check | `enforceAccess()` before handler dispatch |
|
||||||
|
| Subscription handlers (`async function*`) | `mpsc::Sender<T>` streaming response |
|
||||||
|
|
||||||
|
### Call Protocol Events and Context
|
||||||
|
|
||||||
|
The call protocol's `EventEnvelope` carries the context fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "call.requested",
|
||||||
|
"id": "uuid-123",
|
||||||
|
"payload": {
|
||||||
|
"operationId": "/head/docker/create",
|
||||||
|
"input": { "image": "nginx", "name": "web" },
|
||||||
|
"identity": { "id": "node-abc", "scopes": ["docker:read", "docker:write"] },
|
||||||
|
"parentRequestId": "uuid-122",
|
||||||
|
"deadline": 1712345678000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `CallHandler` in `call.ts` receives this event, constructs an `OperationContext` from the payload, validates access control, and dispatches to the registered handler. The same pattern applies in Rust — the `buildCallHandler` function creates the context from the event and calls `registry.execute()`.
|
||||||
|
|
||||||
|
### Mutations and Events
|
||||||
|
|
||||||
|
A mutation handler can trigger side effects after the main operation:
|
||||||
|
|
||||||
|
```
|
||||||
|
handler(input, context) {
|
||||||
|
// 1. Perform mutation (e.g., create a node in storage)
|
||||||
|
let result = storage.create_node(...);
|
||||||
|
|
||||||
|
// 2. Trigger side effects (e.g., publish event)
|
||||||
|
// This is an integration event, not a domain event
|
||||||
|
pubsub.publish("call.responded", "", {
|
||||||
|
requestId: context.request_id,
|
||||||
|
output: result,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Following the event boundary discipline: the mutation itself uses honker's `stream_publish` for internal state management (domain event), and the call protocol `call.responded` is the integration event that other nodes/services react to. The handler doesn't publish honker events directly — that's the storage service's internal concern. The handler calls `context.env.storage.addNode()` and the storage service internally publishes to honker before returning.
|
||||||
|
|
||||||
|
### Adapters: MCP and OpenAPI
|
||||||
|
|
||||||
|
The `from_mcp` and `from_openapi` adapters in `@alkdev/operations` demonstrate how external protocols map to the operation model:
|
||||||
|
|
||||||
|
- **MCP**: Each MCP tool becomes a `MUTATION` operation. The handler calls `client.callTool()` and wraps the result in a `ResponseEnvelope` with `source: "mcp"`.
|
||||||
|
- **OpenAPI**: Each HTTP endpoint becomes a `QUERY`, `MUTATION`, or `SUBSCRIPTION` (detected from `text/event-stream` responses). The handler makes HTTP requests and wraps results with `source: "http"`.
|
||||||
|
|
||||||
|
These adapters will need to map to irpc in Rust. The `ResponseEnvelope` pattern (wrapping results with source metadata) carries over directly. The `OpenAPIServiceRegistry` and `MCPClientLoader` patterns become irpc service initializers that register their operations with the call protocol's `OperationRegistry`.
|
||||||
|
|
||||||
|
The key insight: **adapters are just like any other service** — they register operations in the registry and get an `OperationContext` with `env` access. An MCP adapter can call `/head/secrets/derive` just as easily as a local handler can.
|
||||||
|
|
||||||
## Service Composition
|
## Service Composition
|
||||||
|
|
||||||
### Minimal Deployment (Single Node, CLI)
|
### Minimal Deployment (Single Node, CLI)
|
||||||
|
|||||||
Reference in New Issue
Block a user