docs(http): decompose alknet-http spec into 19 implementation tasks

Break the alknet-http architecture spec into atomic, dependency-ordered
tasks in tasks/http/, following the taskgraph frontmatter conventions
used by the call/core/vault crates.

Tasks span 7 phases across 5 module subdirectories (server/, gateway/,
client/, adapters/, websocket/):
- Phase 0: crate-init (foundation)
- Phase 1: gateway-dispatch-spine, error-mapping, shared-http-client
  (shared infrastructure)
- Phase 2: http-adapter, bearer-auth-middleware, gateway-endpoints,
  healthz-decoy (HTTP server surface)
- Phase 3: to-openapi (OpenAPI gateway projection)
- Phase 4: from-openapi (OpenAPI adapter, reqwest forwarding)
- Phase 5: dispatcher-transport-abstraction, upgrade-handler,
  connection-overlay (WebSocket browser bidirectional path)
- Phase 6: from-mcp, to-mcp (MCP adapters, feature-gated)
- Phase 7: review-http, review-websocket, review-mcp, review-http-final
  (quality checkpoints)

The gateway-dispatch-spine task implements the thin shared core
recommended by the gateway-factoring research (concrete struct, not a
trait). The dispatcher-transport-abstraction task is a cross-crate
change to alknet-call (exposes EventEnvelope-level dispatch API for
non-QUIC transports) — the highest-risk task. WebTransport/h3 is
deferred per ADR-044 and has no tasks; from_wss is out of scope.

Validated: 19 tasks, no cycles, 8 parallel generations, critical path
length 8 (through the WebSocket strand).
This commit is contained in:
2026-07-01 07:11:17 +00:00
parent e0c6f61e6a
commit e855c8c7eb
19 changed files with 3493 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
---
id: http/gateway/error-mapping
name: Implement CallError-to-HTTP-status error mapping (ADR-023)
status: pending
depends_on: [http/crate-init]
scope: narrow
risk: low
impact: component
level: implementation
---
## Description
Implement the `CallError` code → HTTP status code mapping in
`src/gateway/error.rs`. This is the error-mapping table the HTTP server's
gateway endpoints use to translate call-protocol `CallError` codes into
HTTP response status codes (ADR-023). The mapping is a two-way-door
default (the exact status for ambiguous codes can be refined
additively); the one-way constraint is that protocol-level and
operation-level codes are distinct (ADR-023) and `from_openapi`-imported
codes are prefixed `HTTP_<status>` to avoid collision with protocol
codes.
### The mapping table (http-server.md §"Error Mapping")
| Call `code` | HTTP status | Notes |
|-------------|-------------|-------|
| `NOT_FOUND` (operation not registered, or Internal op) | `404` | |
| `FORBIDDEN` (insufficient scopes, or unauthenticated) | `401` (no token) / `403` (token present) | |
| `INVALID_INPUT` (schema mismatch) | `422` | |
| `TIMEOUT` | `504` | `retryable: true` |
| `INTERNAL` | `500` | |
| Operation-level domain code with `http_status` (ADR-023) | the declared `http_status` | `from_openapi`-imported ops carry the original status |
| Operation-level domain code without `http_status` | `500` | |
### The `HTTP_<status>` prefix rule (ADR-023 §5)
`from_openapi` maps OpenAPI non-2xx response status codes to
`ErrorDefinition`s with codes prefixed `HTTP_` + the status number:
```rust
// OpenAPI: 404: { schema: NotFoundError }
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
```
The normative rule (review #002 W20): `from_openapi` must not produce
error codes that collide with the five protocol-level codes (`NOT_FOUND`,
`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`). The `HTTP_<status>`
prefix enforces this.
### `retryable` → `Retry-After` hint
The `retryable` field from `CallError` maps to an HTTP `Retry-After` hint
for `503`/`429`-class errors (operation-level codes with `http_status` in
that range). The hint is optional; if the operation-level error does not
carry a retry-after value, no header is added.
### API
```rust
/// Map a CallError to an HTTP status code (ADR-023).
pub fn call_error_to_http_status(error: &CallError) -> u16;
/// Map a CallError to an HTTP response, including the Retry-After hint
/// when applicable. The body is the serialized CallError (or its
/// `details` field).
pub fn call_error_to_http_response(error: &CallError) -> axum::response::Response;
```
The `FORBIDDEN` case needs the caller's identity state to distinguish
`401` (no token) from `403` (token present but insufficient scopes). The
mapping function takes an `Option<Identity>` (or a flag) so the gateway
endpoint can pass the resolved identity through:
```rust
/// Map a CallError to an HTTP status code, considering whether the caller
/// was authenticated (FORBIDDEN → 401 if no identity, 403 if identity
/// present but insufficient scopes).
pub fn call_error_to_http_status_with_identity(
error: &CallError,
identity: Option<&Identity>,
) -> u16;
```
### What this task does NOT do
- **No `to_openapi` error projection.** `to_openapi` projects
`error_schemas` to the gateway endpoint's response definitions (the
OpenAPI doc's `responses` block). That is the `to-openapi` task, not
this one. This task is the runtime HTTP response mapping.
- **No `from_openapi` error import.** `from_openapi` builds
`ErrorDefinition`s from OpenAPI non-2xx responses with the `HTTP_<status>`
prefix. That is the `from-openapi` task. This task consumes the
resulting `CallError` codes at runtime.
## Acceptance Criteria
- [ ] `call_error_to_http_status(error: &CallError) -> u16` implemented
- [ ] `NOT_FOUND` → 404
- [ ] `FORBIDDEN` → 401 (no identity) / 403 (identity present)
- [ ] `INVALID_INPUT` → 422
- [ ] `TIMEOUT` → 504
- [ ] `INTERNAL` → 500
- [ ] Operation-level code with `http_status` → declared status
- [ ] Operation-level code without `http_status` → 500
- [ ] `HTTP_<status>`-prefixed codes (from `from_openapi`) → the status number
- [ ] `call_error_to_http_response(error)` builds an `axum::response::Response` with the status + JSON body
- [ ] `retryable: true` on `503`/`429`-class errors → `Retry-After` header (when value present)
- [ ] `call_error_to_http_status_with_identity(error, identity)` for the 401/403 split
- [ ] Unit test: each protocol code maps to the correct status
- [ ] Unit test: operation-level code with `http_status` maps to declared status
- [ ] Unit test: operation-level code without `http_status` maps to 500
- [ ] Unit test: `HTTP_404` code maps to 404 (not collided with protocol `NOT_FOUND`)
- [ ] Unit test: `FORBIDDEN` with `None` identity → 401
- [ ] Unit test: `FORBIDDEN` with `Some(identity)` → 403
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/http/http-server.md — Error Mapping table (§"Error Mapping")
- docs/architecture/crates/http/http-adapters.md — Error Fidelity (§"Error Fidelity (ADR-023)")
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (protocol/operation codes distinct, HTTP_<status> prefix)
## Notes
> The mapping is a two-way-door default (the exact status for ambiguous
> codes can be refined additively); the one-way constraint is that
> protocol-level and operation-level codes are distinct (ADR-023) and
> from_openapi-imported codes are prefixed HTTP_<status>. The FORBIDDEN
> case needs the caller's identity state to distinguish 401 (no token)
> from 403 (token present but insufficient scopes). This task is the
> runtime HTTP response mapping; the to_openapi doc-level error
> projection is the to-openapi task, and the from_openapi error import
> is the from-openapi task.
## Summary
> To be filled on completion

View File

@@ -0,0 +1,183 @@
---
id: http/gateway/gateway-dispatch-spine
name: Implement GatewayDispatch shared dispatch spine (thin concrete struct, not a trait)
status: pending
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<OperationRegistry>` +
`Arc<dyn IdentityProvider>` 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<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
}
impl GatewayDispatch {
pub fn new(
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Self { ... }
/// Resolve a bearer token to an Identity (shared by both gateways'
/// axum auth middleware).
pub fn resolve_bearer(&self, token: &AuthToken) -> Option<Identity> {
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<Identity>,
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<OperationRegistry>` + `Arc<dyn IdentityProvider>`
- [ ] `resolve_bearer(&self, token: &AuthToken) -> Option<Identity>` 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
> To be filled on completion