269 lines
12 KiB
Markdown
269 lines
12 KiB
Markdown
---
|
|
id: call/protocol/call-adapter
|
|
name: Implement CallAdapter (ProtocolHandler for alknet/call) with stream handling, identity resolution, and root context construction
|
|
status: completed
|
|
depends_on: [call/protocol/call-connection, call/registry/operation-env, call/registry/service-discovery, core/endpoint]
|
|
scope: broad
|
|
risk: high
|
|
impact: component
|
|
level: implementation
|
|
---
|
|
|
|
## Description
|
|
|
|
Implement `CallAdapter` in `src/protocol/adapter.rs`. This is the
|
|
`ProtocolHandler` implementation for ALPN `alknet/call` — the merge point of the
|
|
registry and protocol strands. It ties everything together: stream handling,
|
|
identity resolution, root context construction, env composition, dispatch.
|
|
|
|
### CallAdapter struct
|
|
|
|
```rust
|
|
pub struct CallAdapter {
|
|
registry: Arc<OperationRegistry>, // Layer 0 — curated, immutable
|
|
identity_provider: Arc<dyn IdentityProvider>,
|
|
session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>, // Layer 1
|
|
default_timeout: Duration, // 30s default
|
|
}
|
|
|
|
impl CallAdapter {
|
|
pub fn new(registry: Arc<OperationRegistry>, identity_provider: Arc<dyn IdentityProvider>) -> Self {
|
|
Self { registry, identity_provider, session_source: None,
|
|
default_timeout: Duration::from_secs(30) }
|
|
}
|
|
|
|
pub fn with_session_source(mut self, source: Arc<dyn SessionOverlaySource + Send + Sync>) -> Self {
|
|
self.session_source = Some(source);
|
|
self
|
|
}
|
|
|
|
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
|
self.default_timeout = timeout;
|
|
self
|
|
}
|
|
}
|
|
```
|
|
|
|
### SessionOverlaySource trait
|
|
|
|
```rust
|
|
pub trait SessionOverlaySource: Send + Sync {
|
|
fn overlay_for(&self, context: &OperationContext) -> Option<Arc<dyn OperationEnv + Send + Sync>>;
|
|
}
|
|
```
|
|
|
|
Defined in alknet-call because CallAdapter must name the type — alknet-call
|
|
cannot depend on alknet-agent (agent depends on call, not reverse). The agent
|
|
crate implements this trait; alknet-call defines it. Same pattern as
|
|
IdentityProvider (ADR-004).
|
|
|
|
### ProtocolHandler impl
|
|
|
|
```rust
|
|
#[async_trait]
|
|
impl ProtocolHandler for CallAdapter {
|
|
fn alpn(&self) -> &'static [u8] { b"alknet/call" }
|
|
|
|
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
|
|
// 1. Create CallConnection from the Connection
|
|
// 2. Spawn a task that continuously calls connection.accept_bi()
|
|
// 3. For each accepted stream, read EventEnvelope frames (FrameFramedReader)
|
|
// 4. Dispatch call.requested events to the operation registry
|
|
// 5. Write response EventEnvelope frames (FrameFramedWriter)
|
|
// 6. Manage PendingRequestMap for outgoing calls
|
|
// 7. On connection close: fail all pending, return Ok or Err(ConnectionClosed)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Stream handling
|
|
|
|
The adapter:
|
|
1. Spawns a task that continuously calls `connection.accept_bi()` to receive
|
|
incoming streams
|
|
2. For each accepted stream, reads `EventEnvelope` frames using
|
|
`FrameFramedReader`
|
|
3. Dispatches `call.requested` events to the operation registry
|
|
4. Writes response `EventEnvelope` frames using `FrameFramedWriter`
|
|
5. Manages `PendingRequestMap` for outgoing calls initiated by the server
|
|
|
|
For outgoing calls (server → client), the adapter:
|
|
1. Opens a bidirectional stream with `connection.open_bi()`
|
|
2. Sends `call.requested` on that stream
|
|
3. Adds the request ID to the `PendingRequestMap`
|
|
4. Reads responses from any stream, correlates by ID
|
|
|
|
### Identity resolution (per-request)
|
|
|
|
The CallAdapter resolves identity per-request, not per-connection:
|
|
|
|
1. The endpoint provides `AuthContext` with whatever identity it resolved at
|
|
the TLS layer (may be `None`)
|
|
2. When a `call.requested` event arrives, the CallAdapter constructs an
|
|
`OperationContext` with the connection-level `AuthContext.identity`
|
|
3. If the `call.requested` payload includes an `auth_token` field, the
|
|
CallAdapter resolves it using `IdentityProvider::resolve_from_token()`. If
|
|
resolution succeeds, the resulting `Identity` replaces the connection-level
|
|
identity in the `OperationContext`. If resolution fails, the request
|
|
proceeds with the connection-level identity (which may be `None`)
|
|
4. The `OperationContext.identity` is passed to the `OperationRegistry` for
|
|
ACL checking
|
|
5. If `identity` is `None` and the operation's `AccessControl` has
|
|
restrictions, the registry returns `FORBIDDEN` with message
|
|
`"authentication required"`
|
|
|
|
**Key point**: Identity is resolved per-request. This allows a single
|
|
connection to upgrade authentication mid-session and allows different operations
|
|
on the same connection to have different identity levels.
|
|
|
|
### Root OperationContext construction
|
|
|
|
When a `call.requested` arrives from the wire, the CallAdapter constructs the
|
|
root `OperationContext` — the entry point of the call tree. This sets
|
|
`internal: false`, meaning ACL runs against the caller's `identity`, not a
|
|
handler's composition authority (ADR-015, ADR-022).
|
|
|
|
```rust
|
|
fn build_root_context(
|
|
&self,
|
|
request_id: String,
|
|
operation_name: &str,
|
|
identity: Option<Identity>,
|
|
/* connection, session */
|
|
) -> OperationContext {
|
|
let registration = self.registry.registration(operation_name);
|
|
OperationContext {
|
|
request_id,
|
|
parent_request_id: None, // wire request — top of call tree
|
|
identity: identity.clone(), // caller's identity (inbound)
|
|
handler_identity: registration.composition_authority.clone(),
|
|
capabilities: registration.capabilities.clone(),
|
|
metadata: HashMap::new(),
|
|
deadline: Some(Instant::now() + self.default_timeout),
|
|
scoped_env: registration.scoped_env.clone()
|
|
.unwrap_or_else(ScopedOperationEnv::empty),
|
|
env: self.compose_root_env(/* connection, session */),
|
|
abort_policy: AbortPolicy::default(), // abort-dependents
|
|
internal: false, // external call — ACL against caller identity
|
|
}
|
|
}
|
|
```
|
|
|
|
### compose_root_env
|
|
|
|
The per-call `env` composition (ADR-024) builds a `CompositeOperationEnv` from:
|
|
- Layer 0: `LocalOperationEnv` (curated registry)
|
|
- Layer 1: session overlay (if active, from `session_source.overlay_for()`)
|
|
- Layer 2: connection overlay (from `CallConnection.overlay_env()`)
|
|
|
|
```rust
|
|
fn compose_root_env(&self, connection: &CallConnection, context: &OperationContext) -> Arc<dyn OperationEnv + Send + Sync> {
|
|
let base = Arc::new(LocalOperationEnv { registry: self.registry.clone() });
|
|
let session = self.session_source.as_ref()
|
|
.and_then(|s| s.overlay_for(context));
|
|
let connection_overlay = connection.overlay_env();
|
|
Arc::new(CompositeOperationEnv { session, connection: Some(connection_overlay), base })
|
|
}
|
|
```
|
|
|
|
### operationId normalization
|
|
|
|
The `call.requested` payload's `operationId` has a leading slash (`/fs/readFile`).
|
|
The CallAdapter strips it before registry lookup (`fs/readFile`). This is a
|
|
single rule applied consistently — the registry stores names without leading
|
|
slash, the wire format adds it.
|
|
|
|
### ResponseEnvelope → EventEnvelope
|
|
|
|
The CallAdapter converts `ResponseEnvelope` (from local dispatch) to
|
|
`EventEnvelope` for the wire:
|
|
|
|
| `ResponseEnvelope` | `EventEnvelope` |
|
|
|--------------------|-----------------|
|
|
| `Ok(value)` | `{ type: "call.responded", id: request_id, payload: { output: value } }` |
|
|
| `Err(call_error)` | `{ type: "call.error", id: request_id, payload: <serialized CallError> }` |
|
|
|
|
For subscriptions, each `call.responded` is a separate `EventEnvelope` with the
|
|
same `id`; `call.completed` is `{ type: "call.completed", id, payload: {} }`.
|
|
|
|
### Timeout handling
|
|
|
|
- Default timeout for wire calls is 30 seconds (`default_timeout`)
|
|
- `build_root_context` sets `OperationContext.deadline` to `now + default_timeout`
|
|
- Composed calls inherit the parent's deadline (children do NOT get a fresh 30s)
|
|
- A composed call that exceeds the deadline is cancelled and returns
|
|
`CallError { code: "TIMEOUT", retryable: true }`
|
|
- Subscriptions default to no deadline (`deadline: None` — unbounded); the
|
|
client can specify a timeout in the `call.requested` payload
|
|
- The `PendingRequestMap` sweeper runs every 10 seconds and removes expired
|
|
wire entries
|
|
|
|
### Error handling in handle()
|
|
|
|
- If a handler panics, the stream is closed and the PendingRequestMap entry is
|
|
cleaned up by the next sweeper pass. Other streams and the connection are
|
|
unaffected.
|
|
- Connection drop: all pending requests failed with `call.error` code
|
|
`INTERNAL` and message `"connection closed"`. All subscription channels
|
|
closed. `handle()` returns `Ok(())` (clean) or `Err(ConnectionClosed)`.
|
|
- Stream reset: `FrameFramedReader` returns an error. If subscription, remove
|
|
PendingRequestMap entry, close mpsc. If call, resolve oneshot with error. No
|
|
`call.aborted` sent — stream is gone.
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] `CallAdapter` struct with registry, identity_provider, session_source, default_timeout
|
|
- [ ] `CallAdapter::new()`, `with_session_source()`, `with_timeout()` constructors
|
|
- [ ] `SessionOverlaySource` trait defined with `overlay_for()` method
|
|
- [ ] `ProtocolHandler::alpn()` returns `b"alknet/call"`
|
|
- [ ] `handle()` accepts streams, reads EventEnvelope frames, dispatches
|
|
- [ ] `handle()` spawns task for continuous `accept_bi()`
|
|
- [ ] Outgoing calls: open_bi, send call.requested, add to PendingRequestMap
|
|
- [ ] Identity resolution: AuthContext.identity used, auth_token overrides per-request
|
|
- [ ] auth_token resolution failure → proceed with connection-level identity
|
|
- [ ] `build_root_context` sets internal: false, deadline, capabilities from registration
|
|
- [ ] `compose_root_env` builds CompositeOperationEnv (base + session + connection)
|
|
- [ ] operationId leading slash stripped before registry lookup
|
|
- [ ] ResponseEnvelope → EventEnvelope conversion (Ok → responded, Err → error)
|
|
- [ ] Subscriptions: multiple call.responded with same id, then call.completed
|
|
- [ ] Timeout: 30s default, composed calls inherit parent deadline
|
|
- [ ] Handler panic: stream closed, PendingRequestMap cleaned up, others unaffected
|
|
- [ ] Connection drop: fail all pending with INTERNAL, return Ok or Err
|
|
- [ ] Unit test: CallAdapter alpn returns b"alknet/call"
|
|
- [ ] Integration test: call.requested → dispatch → call.responded round-trip
|
|
- [ ] Integration test: auth_token overrides connection-level identity
|
|
- [ ] Integration test: Internal op called from wire → NOT_FOUND
|
|
- [ ] Integration test: ACL denied → FORBIDDEN
|
|
- [ ] `cargo test -p alknet-call` succeeds
|
|
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
|
|
|
## References
|
|
|
|
- docs/architecture/crates/call/call-protocol.md — CallAdapter, stream handling, root context
|
|
- docs/architecture/crates/call/operation-registry.md — OperationContext construction
|
|
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (internal: false for wire)
|
|
- docs/architecture/decisions/024-operation-registry-layering.md — ADR-024 (env composition)
|
|
- docs/architecture/decisions/012-call-protocol-stream-model.md — ADR-012
|
|
|
|
## Notes
|
|
|
|
> This is the merge point of the registry and protocol strands — the highest-
|
|
> risk task in the call crate. It ties together stream handling, identity
|
|
> resolution, root context construction, env composition, and dispatch. The
|
|
> per-request identity resolution (auth_token overrides connection-level) is
|
|
> important — a single connection can upgrade auth mid-session. The
|
|
> compose_root_env builds the CompositeOperationEnv per call from the active
|
|
> layers. operationId on the wire has a leading slash; strip it before lookup.
|
|
|
|
## Summary
|
|
|
|
Implemented `CallAdapter` (`ProtocolHandler` for ALPN `alknet/call`) in
|
|
`protocol/adapter.rs`: stream handling via `accept_bi` loop, per-request identity
|
|
resolution (`auth_token` overrides connection identity, falls back on failure),
|
|
root context construction (`internal: false`, deadline, capabilities + scoped_env
|
|
from registration bundle), env composition (`CompositeOperationEnv` with Layer 0
|
|
base + Layer 2 connection overlay + optional Layer 1 session overlay),
|
|
`operationId` leading slash stripped, `ResponseEnvelope`→`EventEnvelope` conversion,
|
|
`PendingRequestMap` sweeper every 10s, connection drop fails all pending.
|
|
`SessionOverlaySource` trait. Added `pending()` accessor to `CallConnection`.
|
|
22 unit tests (142 total in call crate). Clippy clean. Merged to develop. |