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:
139
tasks/http/gateway/error-mapping.md
Normal file
139
tasks/http/gateway/error-mapping.md
Normal 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
|
||||
183
tasks/http/gateway/gateway-dispatch-spine.md
Normal file
183
tasks/http/gateway/gateway-dispatch-spine.md
Normal 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
|
||||
Reference in New Issue
Block a user