--- id: http/gateway/gateway-dispatch-spine name: Implement GatewayDispatch shared dispatch spine (thin concrete struct, not a trait) status: completed depends_on: [http/crate-init] scope: narrow risk: medium impact: component level: implementation --- ## Description Implement the shared dispatch spine for the `to_*` gateway projections (`to_openapi`, `to_mcp`) in `src/gateway/dispatch.rs`. This is the thin shared core recommended by the gateway-factoring research: a **concrete struct, not a trait**. It holds `Arc` + `Arc` and exposes a `resolve_bearer()` + `invoke()` method pair returning the neutral `ResponseEnvelope`. Both gateways call it as a library function; each gateway then maps the `ResponseEnvelope` to its own wire shape. This is the security-relevant shared piece: identity resolution, root `OperationContext` construction, and the `OperationRegistry::invoke()` call. A divergence here (one gateway resolving identity differently, or building `OperationContext` with a different `internal` flag, or mapping `CallError` inconsistently) would be a real security/correctness bug. Extracting the spine now makes the two gateways *provably* identical on the security-relevant axis (auth, authority, ACL) and lets them diverge only on the wire-framing axis (where divergence is correct). ### The struct (research §5.1) ```rust /// Shared dispatch spine for the `to_*` gateway projections. /// Resolves identity, builds a root OperationContext, invokes the registry, /// returns the neutral ResponseEnvelope. Each gateway maps the envelope to /// its own wire shape. pub struct GatewayDispatch { registry: Arc, identity_provider: Arc, } impl GatewayDispatch { pub fn new( registry: Arc, identity_provider: Arc, ) -> Self { ... } /// Resolve a bearer token to an Identity (shared by both gateways' /// axum auth middleware). pub fn resolve_bearer(&self, token: &AuthToken) -> Option { self.identity_provider.resolve_from_token(token) } /// Invoke an operation as a wire-ingress caller. `internal: false`, /// `forwarded_for: None`, fresh request_id. Returns the neutral /// ResponseEnvelope; the gateway maps it to its wire shape. pub async fn invoke( &self, identity: Option, op: &str, input: Value, ) -> ResponseEnvelope { // build root OperationContext, call self.registry.invoke(op, input, ctx) // ... } } ``` ### Root OperationContext construction The `invoke()` method builds a root `OperationContext` for a wire-ingress call (the same shape `CallAdapter::build_root_context` builds for `alknet/call` wire requests): - `internal: false` — ACL runs against the caller's `identity`, not a handler's composition authority (ADR-015). - `forwarded_for: None` — wire-ingress only (ADR-032). - `identity` = the resolved bearer identity (from `resolve_bearer`). - `handler_identity` = the registration bundle's `composition_authority`. - `capabilities` = the registration bundle's capabilities. - `scoped_env` = the registration bundle's `scoped_env` (or empty). - `request_id` = fresh UUID v4 (`generate_request_id()`). - `deadline` = `now + default_timeout` (30s default). - `env` = a `LocalOperationEnv` over the registry (the gateway dispatch path does not compose peer/session overlays — it is a flat invoke). Coordinate with the existing `Dispatcher::build_root_context` in alknet-call (`protocol/dispatch.rs`): if that logic can be extracted as a shared free function (it should be — it takes `identity`, `capabilities`, `env`, `deadline` and returns an `OperationContext`), call it from both `Dispatcher` and `GatewayDispatch`. If it is tangled with `CallAdapter`-specific state, duplicate the construction logic here (the invariants — `internal: false`, `forwarded_for: None` — are the load-bearing part; the construction itself is mechanical). See research §6 open question #1. ### What this task does NOT do - **No `GatewayDispatch` trait.** A concrete struct, not a polymorphic trait. The research (§5.2) rules this out: a trait would need an associated output type (HTTP `Response` vs `CallToolResult`), at which point it has no shared method bodies. - **No `into_wire()` method.** The `ResponseEnvelope` → wire mapping is per-gateway; do not parameterize the core over it. - **No streaming abstraction.** `/subscribe` SSE is `to_openapi`-only; do not build a `GatewayStream` trait for one implementation. - **No discovery abstraction.** `services/list` is the shared backend (already in `OperationRegistry`); the discovery *framing* (OpenAPI `/search` vs MCP `tools/list` + `search` tool) is per-gateway. - **No versioning.** `info.version` is `to_openapi`-only. - **No `batch` method.** `batch` is a loop over `invoke()` in each gateway (research §6 open question #3 — confirm `batch` is genuinely just a loop, no shared batch-specific state). ### `services/list` / `services/schema` dispatch The gateway's `search`/`schema` endpoints/tools dispatch `services/list` and `services/schema` — these are registered operations in the `OperationRegistry`, so `GatewayDispatch::invoke()` handles them unchanged (it calls `OperationRegistry::invoke()`, which works for `services/list` and `services/schema`). The `AccessControl`-filtered listing lives in the `services/list` handler (already in the registry), not in the gateway. Confirm via a spike that the filtering sees the *caller's* identity when invoked through `GatewayDispatch::invoke` (it should — `services/list` is `AccessControl::check(identity)`-filtered, and `GatewayDispatch` passes the resolved identity as the caller). See research §6 open question #4. ## Acceptance Criteria - [ ] `GatewayDispatch` struct defined in `src/gateway/dispatch.rs` - [ ] Holds `Arc` + `Arc` - [ ] `resolve_bearer(&self, token: &AuthToken) -> Option` delegates to `identity_provider.resolve_from_token` - [ ] `invoke(&self, identity, op, input) -> ResponseEnvelope` builds root context and dispatches - [ ] Root `OperationContext` has `internal: false`, `forwarded_for: None`, fresh `request_id` - [ ] `handler_identity` from registration bundle's `composition_authority` - [ ] `capabilities` from registration bundle - [ ] `scoped_env` from registration bundle (or empty) - [ ] `deadline` = `now + 30s` default - [ ] `invoke()` calls `OperationRegistry::invoke(op, input, ctx)` - [ ] `invoke()` works for `services/list` and `services/schema` (registered ops) - [ ] `AccessControl`-filtering in `services/list` sees the caller's resolved identity - [ ] No `GatewayDispatch` trait (concrete struct only) - [ ] No `into_wire()` method (per-gateway mapping stays out of the core) - [ ] No streaming abstraction (per-gateway) - [ ] `GatewayDispatch` is `pub` and re-exported from `lib.rs` - [ ] Unit test: `invoke()` dispatches a registered op and returns `ResponseEnvelope` - [ ] Unit test: `invoke()` for `services/list` returns AccessControl-filtered list - [ ] Unit test: `invoke()` for unregistered op returns `CallError { code: NOT_FOUND }` - [ ] Unit test: `invoke()` for Internal op returns `CallError { code: NOT_FOUND }` (not leaked) - [ ] Unit test: `invoke()` with `None` identity + restricted op → `FORBIDDEN` - [ ] `cargo test -p alknet-http` succeeds - [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings ## References - docs/research/alknet-http-gateway-factoring/findings.md — research recommendation (thin shared core, not a trait) - docs/architecture/crates/http/http-adapters.md — to_openapi dispatch (§"Shared dispatch spine with to_mcp") - docs/architecture/crates/http/http-mcp.md — to_mcp dispatch (§"Shared dispatch spine with to_openapi") - docs/architecture/crates/call/operation-registry.md — OperationRegistry::invoke(), OperationContext construction - docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (internal: false for wire) - docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032 (forwarded_for: None for wire-ingress) ## Notes > The dispatch spine is the security-relevant shared piece. A divergence > here (identity resolution, context construction, invoke shape) would be > a security bug; extracting the spine now makes the two gateways provably > identical on the security axis. The research recommends a concrete > struct, not a trait — a trait would need an associated output type > (HTTP Response vs CallToolResult), at which point it has no shared > method bodies. Coordinate with the existing > `Dispatcher::build_root_context` in alknet-call: if it can be extracted > as a shared free function, call it from both Dispatcher and > GatewayDispatch; otherwise duplicate the construction logic (the > invariants are the load-bearing part). The `batch` endpoint is a loop > over `invoke()` in each gateway, not a shared method. ## Summary > GatewayDispatch concrete struct implemented in src/gateway/dispatch.rs. Holds > Arc + Arc. resolve_bearer() delegates > to identity_provider.resolve_from_token. invoke() builds root OperationContext > (internal:false, forwarded_for:None, fresh UUID v4 request_id, deadline now+30s, > registration bundle's composition_authority/capabilities/scoped_env) and calls > OperationRegistry::invoke. Duplicated build_root_context construction (alknet-call > version is pub(crate) + tangled with CallConnection overlays; invariants identical). > 14 unit tests covering dispatch, services/list AccessControl filtering, NOT_FOUND > for unregistered/Internal ops, FORBIDDEN for unauthorized, concrete-struct-not-trait. > Re-exported from lib.rs. Build/clippy/test all pass.