docs(http): resolve OQ-39; add ADRs 045-047; record pubsub prior art for WS path
OQ-39 (to_openapi published-spec versioning) resolved by ADR-045:
info.version semver tracks the gateway endpoint contract, not the
operation set — per-caller operations discovered via /search do not
bump the version. The gateway pattern (ADR-042) dissolved most of the
original churn concern.
ADR-046: assembly-layer custom HTTP routes on HttpAdapter. The HTTP
router had no documented extension point for deployment-specific
endpoints (e.g., an OAI-compatible proxy at /v1/chat/completions). Adds
extra_routes: Option<Router> at construction; raw HTTP, not operations;
default surface takes precedence on collision. The mechanism is the
one-way door; specific routes are two-way.
ADR-047: remove the direct-call POST /{service}/{op} HTTP surface. The
gateway /call is the sole invoke path — the simplified contract is a
few fixed endpoints, not a per-operation REST tree. The direct-call
surface re-introduced the 'dump the full API regardless of privs'
failure mode at the HTTP level that the gateway /search was built to
escape. ADR-036's routing decision is superseded; its non-routing
clauses (SSE, Bearer auth, /healthz, stealth, error mapping) survive.
A deployment wanting a REST-like per-operation surface builds it as a
custom route projection (ADR-046).
ADR-044 updated with the tradeoff framing (WSS is the right tool for
the call-protocol-from-browser case; WebTransport is the right tool for
the generalized ALPN-stream-proxy case we don't have yet — coexist, not
migrate) and the @alkdev/pubsub concrete prior art (the EventEnvelope
{type,id,payload} the call protocol was derived from already has a
working WebSocket client/server; the sync is a small adjustment, not a
from-scratch build).
call-protocol.md references the pubsub lineage for the
transport-agnosticism claim.
This commit is contained in:
@@ -540,7 +540,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
||||
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
|
||||
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
|
||||
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14.
|
||||
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation.
|
||||
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044 and [http-server.md](../http/http-server.md) §"WebSocket browser path".
|
||||
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-005 and OQ-13.
|
||||
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-014.
|
||||
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-016.
|
||||
|
||||
@@ -39,7 +39,7 @@ protocol), and hosts the HTTP-backed call-protocol adapters
|
||||
| [023](../../decisions/023-operation-error-schemas.md) | Operation Error Schemas | `from_openapi`/`to_openapi` error fidelity; `HTTP_<status>` error codes |
|
||||
| [027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | TLS Identity Redesign | Browsers require X.509; applies to WebTransport (deferred) and any browser-facing TLS |
|
||||
| [034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Outgoing-Only X.509 and Three Peer Roles | Browsers are not alknet peers (§4 amended by ADR-044 §5 with the addressability rationale) |
|
||||
| [036](../../decisions/036-http-to-call-operation-mapping.md) | HTTP-to-Call Operation Mapping | Direct path mapping; `to_openapi` is projection, not router |
|
||||
| [036](../../decisions/036-http-to-call-operation-mapping.md) | HTTP-to-Call Operation Mapping | ~~Direct path mapping~~ — **routing superseded by ADR-047**; non-routing clauses survive (SSE projection, Bearer auth, `/healthz`, stealth, error mapping) |
|
||||
| [037](../../decisions/037-mcp-stdio-transport-exclusion.md) | MCP Stdio Transport Exclusion | Streamable HTTP only; stdio not built |
|
||||
| [038](../../decisions/038-http3-and-webtransport-as-first-class.md) | HTTP/3 and WebTransport as First-Class HTTP Transports | **Superseded by ADR-044** (anti-pattern correction stands; specific decision reversed) |
|
||||
| [039](../../decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | One crate for server + client host (shared HTTP deps, shared mapping) |
|
||||
@@ -48,6 +48,9 @@ protocol), and hosts the HTTP-backed call-protocol adapters
|
||||
| [042](../../decisions/042-openapi-gateway-pattern.md) | OpenAPI Gateway Pattern for to_openapi | 5 fixed gateway endpoints (search/schema/call/batch/subscribe), not one path per operation; per-caller AccessControl-filtered |
|
||||
| [043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md) | WebTransport as a Bidirectional ALPN Transport Substrate | **Parked** per ADR-044; §2/§3 transfer to WebSocket for v1 |
|
||||
| [044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | Defer h3/WebTransport; Browsers Use WebSocket | `h3`/WebTransport deferred (scope); browser bidirectional path uses WebSocket; "browser is not a peer" rationale |
|
||||
| [045](../../decisions/045-to-openapi-gateway-spec-versioning.md) | to_openapi Gateway-Spec Versioning | Published gateway doc carries `info.version` (semver) tracking the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version |
|
||||
| [046](../../decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | `extra_routes: Option<Router>` at construction; deployments add raw HTTP endpoints (e.g., OAI-compatible proxy, or a REST-like per-operation projection) that coexist with the default surface; default surface takes precedence on collision |
|
||||
| [047](../../decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | `POST /{service}/{op}` direct-call surface removed; the 5 gateway endpoints are the sole invoke path; per-caller `AccessControl`-filtered `/search` is the discovery; ADR-036's non-routing clauses survive |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
@@ -61,7 +64,7 @@ protocol), and hosts the HTTP-backed call-protocol adapters
|
||||
| OQ-26 | OperationAdapter error type | resolved | `AdapterError` variants reused by HTTP adapters |
|
||||
| OQ-37 | X.509 outgoing-only / three peer roles | resolved | Browsers are not peers; hub with mixed fingerprints |
|
||||
| OQ-38 | WebTransport standalone relay service scope | open (scope, not deferral) | The standalone relay (future `alknet-relay`, fork of iroh-relay) — distinct from the in-process ALPN-stream-proxy (ADR-040) |
|
||||
| OQ-39 | `to_openapi` published-spec versioning | open | Versioning strategy for generated OpenAPI specs |
|
||||
| OQ-39 | `to_openapi` published-spec versioning | resolved | `info.version` semver tracks the gateway endpoint contract (ADR-045); per-caller operation set discovered via `/search`, not in the doc |
|
||||
| OQ-40 | reqwest client config and connection pooling | resolved | `ClientWithMiddleware` + `RetryTransientMiddleware` + inlined `RetryAfterMiddleware`; rebuild-and-swap hot-reload |
|
||||
|
||||
## Key Design Principles
|
||||
@@ -72,16 +75,25 @@ protocol), and hosts the HTTP-backed call-protocol adapters
|
||||
forwarding) uses `reqwest`. Both directions share the same HTTP
|
||||
dependencies, which is why they live in one crate rather than being
|
||||
split. See [overview.md](overview.md).
|
||||
2. **The HTTP surface is a projection of the call protocol.** An HTTP
|
||||
request at `POST /fs/readFile` becomes a `call.requested` for
|
||||
`/fs/readFile`. The HTTP path IS the operation path on the
|
||||
**direct-call surface**. `to_openapi` *describes* a different surface
|
||||
— the 5-endpoint gateway (`/search`, `/schema`, `/call`, `/batch`,
|
||||
`/subscribe`) that gates discovery and invocation behind a fixed
|
||||
entry set. See [ADR-036](../../decisions/036-http-to-call-operation-mapping.md)
|
||||
(direct-call surface) and [ADR-042](../../decisions/042-openapi-gateway-pattern.md)
|
||||
(`to_openapi` gateway, superseding ADR-036's original `to_openapi`
|
||||
clause).
|
||||
2. **The HTTP surface is the 5-endpoint gateway — a few fixed
|
||||
endpoints, not a per-operation REST tree.** An HTTP client invokes an
|
||||
operation via `POST /call` with `{ "operation": "/fs/readFile",
|
||||
"input": {...} }`, discovers what it can call via
|
||||
`AccessControl`-filtered `GET /search`, and learns an operation's
|
||||
shape via `GET /schema`. There is no per-operation `POST /{service}/{op}`
|
||||
direct-call surface (removed by ADR-047; the per-caller API surface is
|
||||
the default — the "dump the full API regardless of privs" failure mode
|
||||
is structurally impossible). `to_openapi` *describes* this gateway
|
||||
surface (5 fixed endpoints; per-caller operations discovered via
|
||||
`/search`, not preloaded into the doc). A deployment that wants a
|
||||
REST-like per-operation HTTP surface builds it as a custom route
|
||||
projection (ADR-046). See
|
||||
[ADR-042](../../decisions/042-openapi-gateway-pattern.md) (gateway
|
||||
pattern), [ADR-047](../../decisions/047-remove-direct-call-http-surface.md)
|
||||
(direct-call surface removed; ADR-036's routing superseded, non-routing
|
||||
clauses survive), and
|
||||
[ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md)
|
||||
(custom routes extension point).
|
||||
3. **Standard ALPNs, not alknet ALPNs.** `h2`, `http/1.1` are
|
||||
IANA-registered ALPN strings. Any HTTP client (browser, curl, axios)
|
||||
connects without knowing about alknet — the TLS handshake negotiates
|
||||
|
||||
@@ -376,9 +376,12 @@ once published, the 5-endpoint gateway shape is one-way.
|
||||
- **`to_openapi` is a pure projection.** It consumes the registry, does
|
||||
not produce entries for it. Not an `OperationAdapter`.
|
||||
- **Published `to_openapi` specs are compatibility contracts.** The
|
||||
generated spec's versioning (tied to the registry's `External`
|
||||
operation set version) must be emitted so consumers can detect mapping
|
||||
changes (ADR-017 Consequences, OQ-39).
|
||||
generated gateway doc carries `info.version` (semver) tracking the
|
||||
**gateway endpoint contract**, not the operation set — per-caller
|
||||
operation changes (add/remove/modify, schema changes) do not bump
|
||||
the version (the operation set is discovered via `/search`, not
|
||||
preloaded into the doc). Consumers detect breaking changes via the
|
||||
major version (ADR-017 Consequences, ADR-045, resolves OQ-39).
|
||||
- **`alknet-http` owns its HTTP client.** Shared across all forwarding
|
||||
handlers, constructed once. The shared type is
|
||||
`reqwest_middleware::ClientWithMiddleware` (middleware stack:
|
||||
@@ -402,17 +405,21 @@ once published, the 5-endpoint gateway shape is one-way.
|
||||
| `from_openapi` provenance is a leaf | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | `composition_authority: None`, `scoped_env: None` |
|
||||
| Error fidelity (`HTTP_<status>` codes) | [ADR-023](../../decisions/023-operation-error-schemas.md) | No collision with protocol codes; `to_openapi` projects back |
|
||||
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
|
||||
| HTTP path = operation path (direct-call surface) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `POST /{service}/{op}` → `call.requested` (the direct-call surface; not what `to_openapi` describes) |
|
||||
| HTTP path = operation path (~~direct-call surface~~) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) → superseded by [ADR-047](../../decisions/047-remove-direct-call-http-surface.md) | ~~`POST /{service}/{op}` → `call.requested`~~ — removed; the gateway `/call` with `{ operation, input }` is the sole invoke path; `to_openapi` describes the gateway, not a per-operation surface |
|
||||
| `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | 5 fixed gateway endpoints (search/schema/call/batch/subscribe), not one path per operation; per-caller AccessControl-filtered. Supersedes ADR-036's original `to_openapi` "paths mirror `/{service}/{op}`" clause |
|
||||
| `to_openapi` published-spec versioning | [ADR-045](../../decisions/045-to-openapi-gateway-spec-versioning.md) | `info.version` semver tracks the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-39** (open): `to_openapi` published-spec versioning — the
|
||||
versioning strategy for generated OpenAPI specs (tied to the
|
||||
registry's `External` operation set version). One-way after first
|
||||
publication.
|
||||
- **OQ-39** (resolved): `to_openapi` published-spec versioning —
|
||||
resolved by [ADR-045](../../decisions/045-to-openapi-gateway-spec-versioning.md):
|
||||
`info.version` semver tracks the gateway endpoint contract (major =
|
||||
breaking gateway change, minor = additive, patch = wording); the
|
||||
per-caller operation set is discovered via `/search` and does not bump
|
||||
the version. The additive traditional per-operation-paths projection
|
||||
(ADR-042 §5) versions independently, out of scope.
|
||||
- **OQ-40** (resolved): reqwest client config and connection pooling —
|
||||
`ClientWithMiddleware` + `RetryTransientMiddleware` + inlined
|
||||
`RetryAfterMiddleware`; rebuild-and-swap hot-reload; per-request
|
||||
|
||||
@@ -29,6 +29,11 @@ pub struct HttpAdapter {
|
||||
/// (stealth decoy). Configurable: a static site, a fake 404, a
|
||||
/// redirect. Two-way-door default (ADR-010).
|
||||
decoy: DecoyConfig,
|
||||
/// Deployment-specific routes added by the assembly layer (ADR-046).
|
||||
/// None = the default surface only. Custom routes are raw HTTP, not
|
||||
/// call-protocol operations; they coexist with the default surface and
|
||||
/// are not described by `to_openapi`.
|
||||
extra_routes: Option<Router>,
|
||||
}
|
||||
|
||||
/// The stealth decoy surface for paths that are not registered
|
||||
@@ -105,63 +110,83 @@ identity provider through the router's state.
|
||||
The axum `Router` is the single routing surface for HTTP requests. It
|
||||
contains:
|
||||
|
||||
- **The direct-call surface** (`POST /{service}/{op}` → `call.requested`
|
||||
dispatch — ADR-036). This is the HTTP projection of the call protocol's
|
||||
`/{service}/{op}` operation path; an HTTP client that knows the
|
||||
operation name calls it directly.
|
||||
- **The `to_openapi` gateway endpoints** (`/search`, `/schema`, `/call`,
|
||||
`/batch`, `/subscribe` — ADR-042). These are the fixed 5-endpoint
|
||||
gateway that an OpenAPI consumer uses to discover and invoke
|
||||
operations without knowing operation names up front. `/call` and
|
||||
`/subscribe` dispatch through the same `OperationRegistry::invoke()`
|
||||
as the direct-call surface; `/search` and `/schema` dispatch the
|
||||
`services/list` / `services/schema` discovery ops. The gateway and
|
||||
the direct-call surface coexist on the same router — they are two
|
||||
projections of the same operation registry, not two registries.
|
||||
`/batch`, `/subscribe` — ADR-042). These 5 fixed endpoints are the
|
||||
sole invoke path over HTTP: an HTTP client invokes an operation via
|
||||
`POST /call` with `{ "operation": "/{service}/{op}", "input": {...} }`,
|
||||
discovers available operations via `GET /search`
|
||||
(`AccessControl`-filtered), and learns an operation's shape via `GET
|
||||
/schema`. `/subscribe` is the SSE streaming invoke path. There is no
|
||||
per-operation `POST /{service}/{op}` direct-call surface — the
|
||||
gateway is the invoke path (ADR-047 supersedes ADR-036's direct-call
|
||||
surface; the simplified contract is a few fixed endpoints, not a
|
||||
per-operation REST tree). `/call` and `/subscribe` dispatch through
|
||||
`OperationRegistry::invoke()`; `/search` and `/schema` dispatch the
|
||||
`services/list` / `services/schema` discovery ops.
|
||||
- `GET /healthz` (raw route, no auth, no call protocol).
|
||||
- `GET /openapi.json` (serves the `to_openapi` projection — the OpenAPI
|
||||
document that *describes* the 5 gateway endpoints. Post-ADR-042 this
|
||||
is the gateway's description doc, not a per-operation REST spec; the
|
||||
doc describes the 5 fixed endpoints, and the per-caller operation
|
||||
surface is discovered via `/search`, not preloaded into `paths`).
|
||||
document that *describes* the 5 gateway endpoints. The doc describes
|
||||
the 5 fixed endpoints, and the per-caller operation surface is
|
||||
discovered via `/search`, not preloaded into `paths`. The doc carries
|
||||
`info.version` (semver) tracking the gateway endpoint contract —
|
||||
consumers detect breaking changes via the major version (ADR-045)).
|
||||
- The stealth decoy fallback (unknown paths).
|
||||
- (Feature-gated) `POST /mcp` (the `to_mcp` streamable HTTP service —
|
||||
[http-mcp.md](http-mcp.md)).
|
||||
- **Deployment-specific custom routes** (ADR-046). The assembly layer
|
||||
may inject an `axum::Router` of extra routes at `HttpAdapter`
|
||||
construction — e.g., an OpenAI-compatible proxy at
|
||||
`/v1/chat/completions` that dispatches into the registry. These are
|
||||
raw HTTP, not call-protocol operations: not in the
|
||||
`OperationRegistry`, not discoverable via `/search`, not described
|
||||
by `to_openapi`. The default surface's reserved paths take precedence
|
||||
on collision; custom routes namespace away from the reserved set
|
||||
naturally (`/v1/...`). A deployment that passes no extra routes gets
|
||||
exactly the default surface above. A deployment that wants a
|
||||
REST-like per-operation HTTP surface (the former direct-call shape)
|
||||
builds it as a custom route projection (ADR-047 §4). See ADR-046 and
|
||||
§"Custom routes" below.
|
||||
|
||||
A single HTTP/2 or HTTP/1.1 connection multiplexes multiple requests
|
||||
over the one bidirectional stream (HTTP/2 multiplexing is native;
|
||||
HTTP/1.1 is sequential). The axum router handles each request on a
|
||||
tokio task; the hyper driver manages the connection lifetime.
|
||||
|
||||
### HTTP-to-call dispatch (ADR-036)
|
||||
### HTTP-to-call dispatch (the gateway's `/call`; ADR-042, ADR-047)
|
||||
|
||||
An HTTP request at `POST /fs/readFile` (or `GET /services/list`, or any
|
||||
`/{service}/{op}` path matching a registered `External` operation) is
|
||||
dispatched to the call protocol:
|
||||
An HTTP client invokes an operation via the gateway's `/call` endpoint:
|
||||
|
||||
1. The axum route handler extracts the operation name from the path
|
||||
(`/fs/readFile` → `fs/readFile`, stripping the leading slash — the
|
||||
registry form).
|
||||
1. The axum route handler for `POST /call` reads the JSON body
|
||||
`{ "operation": "/fs/readFile", "input": {...} }`.
|
||||
2. It resolves the caller's identity from the `Authorization: Bearer`
|
||||
header via `identity_provider.resolve_from_token(&AuthToken { raw:
|
||||
token_bytes })`.
|
||||
3. It parses the request body as the operation input (JSON).
|
||||
4. It constructs the root `OperationContext` (caller identity, the
|
||||
3. It constructs the root `OperationContext` (caller identity, the
|
||||
registration bundle's capabilities, the connection's env composition)
|
||||
and dispatches through the `OperationRegistry::invoke()` — the same
|
||||
dispatch path the `CallAdapter` uses for `alknet/call` wire requests.
|
||||
5. The response (`ResponseEnvelope`) is serialized as the HTTP response
|
||||
4. The response (`ResponseEnvelope`) is serialized as the HTTP response
|
||||
body (JSON). Errors map to HTTP status codes (see Error Mapping
|
||||
below).
|
||||
|
||||
`Internal` operations (ADR-015) return `404` on the HTTP handler,
|
||||
matching the call protocol's `NOT_FOUND` for wire calls to Internal
|
||||
ops — the HTTP handler dispatches only `External` operations.
|
||||
`Internal` operations (ADR-015) return `404` (`NOT_FOUND`) — the gateway
|
||||
dispatches only `External` operations, and the caller discovers which
|
||||
`External` operations it can call via the `AccessControl`-filtered
|
||||
`/search` endpoint. This is the per-caller API surface property that
|
||||
the direct-call surface (removed, ADR-047) lacked: an HTTP client cannot
|
||||
stub its toe on a path for an operation it can't call, because there is
|
||||
no per-operation path — `/search` tells it what it can call, `/call`
|
||||
invokes it, and the `AccessControl` check runs on `/call` regardless.
|
||||
|
||||
### Streaming projection (SSE)
|
||||
`/batch` follows the same dispatch path with an array of
|
||||
`{ operation, input }` pairs (OQ-14); `/subscribe` follows it with the
|
||||
SSE streaming projection (below).
|
||||
|
||||
A `Subscription` operation served over `h2`/`http/1.1` projects its
|
||||
`call.responded` stream as Server-Sent Events. The axum route handler:
|
||||
### Streaming projection (SSE — the gateway's `/subscribe`)
|
||||
|
||||
A `Subscription` operation invoked via the gateway's `/subscribe`
|
||||
endpoint projects its `call.responded` stream as Server-Sent Events.
|
||||
The axum route handler:
|
||||
|
||||
- Sets `Content-Type: text/event-stream`.
|
||||
- For each `call.responded` event, writes an SSE `data:` frame (the
|
||||
@@ -333,10 +358,10 @@ routes. `healthz` is the one exception. See ADR-036.
|
||||
|
||||
### Stealth decoy
|
||||
|
||||
For paths that are not registered operations (and not `/healthz`,
|
||||
`/openapi.json`, the `to_openapi` gateway endpoints `/search`/`/schema`/
|
||||
`/call`/`/batch`/`/subscribe`, or the MCP route), the HTTP handler serves
|
||||
a decoy. The decoy is configurable (`DecoyConfig`):
|
||||
For paths that are not the gateway endpoints (`/search`, `/schema`,
|
||||
`/call`, `/batch`, `/subscribe`), `/healthz`, `/openapi.json`, the MCP
|
||||
route, or a custom route per ADR-046), the HTTP handler serves a decoy.
|
||||
The decoy is configurable (`DecoyConfig`):
|
||||
|
||||
- A fake `404 Not Found` (the default — matches the reference
|
||||
implementation's "fake nginx 404").
|
||||
@@ -347,19 +372,71 @@ The decoy is the stealth surface: a port scanner or a client that
|
||||
doesn't offer alknet ALPNs connects on `h2`/`http/1.1` and sees the
|
||||
decoy. Real services use `alknet/ssh`, `alknet/call`, etc. The decoy
|
||||
config is a two-way-door default (an operator picks what to serve); the
|
||||
*existence* of the stealth path is fixed by ADR-010.
|
||||
*existence* of the stealth path is fixed by ADR-010. Custom routes
|
||||
(ADR-046) take precedence over the decoy — a path matched by a custom
|
||||
route is served by it, not the decoy; the decoy is the fallback for
|
||||
paths matched by neither the default surface nor a custom route.
|
||||
|
||||
### Custom routes (ADR-046)
|
||||
|
||||
A deployment that needs HTTP endpoints outside the default surface
|
||||
(direct-call + gateway + `/healthz` + `/openapi.json` + MCP) injects
|
||||
them as an `axum::Router` at `HttpAdapter` construction. The classic use
|
||||
case: an OpenAI-compatible proxy at `/v1/chat/completions` that wraps a
|
||||
call-protocol operation (the deployment parses the OAI request, invokes
|
||||
an `openai/chat` or `agent/chat` op via `OperationRegistry::invoke()`,
|
||||
reformats the response as an OAI response). The hub is a standard
|
||||
alknet node *plus* a deployment-specific HTTP surface.
|
||||
|
||||
Custom routes:
|
||||
|
||||
- Are **raw HTTP**, not call-protocol operations — not registered in the
|
||||
`OperationRegistry`, not discoverable via `/search`, not in the
|
||||
`to_openapi` gateway doc.
|
||||
- **May** dispatch into the registry via
|
||||
`OperationRegistry::invoke()` with a proper `OperationContext`
|
||||
(caller identity from the resolved bearer token) — the OAI proxy
|
||||
does this. Or they may be pure HTTP (a webhook receiver, a static
|
||||
asset server) that never touches the registry.
|
||||
- Run under the **default Bearer-auth middleware**; a route that wants
|
||||
different auth applies its own axum middleware (the deployment owns
|
||||
its custom routes' middleware stack).
|
||||
- **Do not collide** with the reserved default-surface paths
|
||||
(`/{service}/{op}`, `/search`, `/schema`, `/call`, `/batch`,
|
||||
`/subscribe`, `/healthz`, `/openapi.json`, the MCP route) — the
|
||||
default surface wins on collision; custom routes namespace away
|
||||
naturally (`/v1/...`).
|
||||
- Are **not versioned** by `to_openapi` (ADR-045 versions the gateway
|
||||
contract, not custom routes). The deployment versions its own custom
|
||||
routes however it wants.
|
||||
- Are **immutable after construction** (matches OQ-04 / ADR-010's
|
||||
static-registration constraint; the `HttpAdapter` router is built once
|
||||
at startup).
|
||||
|
||||
The extension point is additive: a deployment that passes `None` gets
|
||||
exactly the default surface. The mechanism (the constructor parameter)
|
||||
is the one-way door — once downstream deployments build against it, it's
|
||||
a contract (ADR-046). The specific routes a deployment adds are a
|
||||
two-way door (add/remove freely). See
|
||||
[ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **The HTTP path IS the operation path on the direct-call surface.**
|
||||
`POST /fs/readFile` → `call.requested` for `fs/readFile`. No second
|
||||
routing table for the direct-call surface. See ADR-036. The
|
||||
`to_openapi` gateway (`/search`, `/schema`, `/call`, `/batch`,
|
||||
`/subscribe`) is a separate fixed-endpoint surface (ADR-042) that
|
||||
coexists with the direct-call surface on the same axum `Router`; it
|
||||
does not replace it.
|
||||
- **The gateway is the sole invoke path over HTTP (ADR-042, ADR-047).**
|
||||
The 5 gateway endpoints (`/search`, `/schema`, `/call`, `/batch`,
|
||||
`/subscribe`) are the only way to invoke operations over HTTP. There
|
||||
is no per-operation `POST /{service}/{op}` direct-call surface — the
|
||||
simplified contract is a few fixed endpoints, not a per-operation
|
||||
REST tree. A client invokes an operation via `POST /call` with
|
||||
`{ "operation": "/{service}/{op}", "input": {...} }`; it discovers
|
||||
what it can call via the `AccessControl`-filtered `/search`. The
|
||||
per-caller API surface is the default (the Gitea failure mode — every
|
||||
operation gets a path, every caller sees the full surface — is
|
||||
structurally impossible). A deployment that wants a REST-like
|
||||
per-operation HTTP surface builds it as a custom route projection
|
||||
(ADR-046, ADR-047 §4).
|
||||
- **`External` operations only.** `Internal` operations return `404`
|
||||
on the HTTP handler.
|
||||
on the gateway's `/call`, matching the call protocol's `NOT_FOUND`.
|
||||
- **Bearer-only auth.** `Authorization: Bearer` →
|
||||
`resolve_from_token`. Other HTTP auth schemes are not implemented.
|
||||
- **No secret material in HTTP responses.** The call protocol carries no
|
||||
@@ -372,28 +449,41 @@ config is a two-way-door default (an operator picks what to serve); the
|
||||
messages. `h3`/WebTransport is deferred (ADR-044); the ALPN-stream-proxy
|
||||
(ADR-040) is not available in v1. The `h3` ALPN and its feature gate are
|
||||
not implemented in the initial release.
|
||||
- **Custom routes are raw HTTP, not call-protocol operations
|
||||
(ADR-046).** The assembly layer injects an `axum::Router` of extra
|
||||
routes at `HttpAdapter` construction. They are not in the
|
||||
`OperationRegistry`, not discoverable via `/search`, not in the
|
||||
`to_openapi` doc. They may dispatch into the registry via
|
||||
`OperationRegistry::invoke()` (the OAI-compatible proxy pattern) or
|
||||
be pure HTTP. The default surface's reserved paths take precedence on
|
||||
collision. A deployment that passes no extra routes gets the default
|
||||
surface unchanged.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Direct path mapping (HTTP path = operation path) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `POST /{service}/{op}` → `call.requested` (direct-call surface) |
|
||||
| `to_openapi` gateway endpoints on the router | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | `/search`/`/schema`/`/call`/`/batch`/`/subscribe` coexist with the direct-call surface |
|
||||
| SSE projection for subscriptions over h2/http1.1 | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `call.responded` stream → SSE frames |
|
||||
| ~~Direct path mapping (HTTP path = operation path)~~ | ~~[ADR-036](../../decisions/036-http-to-call-operation-mapping.md)~~ | **Superseded by ADR-047** — direct-call surface removed; gateway `/call` is the sole invoke path |
|
||||
| Gateway is the sole invoke path over HTTP | [ADR-042](../../decisions/042-openapi-gateway-pattern.md), [ADR-047](../../decisions/047-remove-direct-call-http-surface.md) | 5 fixed gateway endpoints (`/search`/`/schema`/`/call`/`/batch`/`/subscribe`); `POST /call` with `{ operation, input }` is the invoke path; per-caller `AccessControl`-filtered `/search` is the discovery; no per-operation HTTP paths |
|
||||
| `to_openapi` published-spec versioning | [ADR-045](../../decisions/045-to-openapi-gateway-spec-versioning.md) | `/openapi.json` carries `info.version` (semver) tracking the gateway contract, not the operation set |
|
||||
| SSE projection for subscriptions (`/subscribe`) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) §Streaming, [ADR-042](../../decisions/042-openapi-gateway-pattern.md) §2 | `call.responded` stream → SSE frames; the gateway's `/subscribe` endpoint is the entry point |
|
||||
| `/healthz` is a raw route | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | No auth, no call protocol |
|
||||
| Stealth decoy | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | HTTP handler on standard ALPNs serves decoy |
|
||||
| Stealth decoy | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | HTTP handler on standard ALPNs serves decoy for non-gateway, non-custom, non-`/healthz` paths |
|
||||
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source (settled) |
|
||||
| WebSocket is the browser bidirectional path | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | Browsers upgrade to WS; `EventEnvelope` over binary messages; `h3`/WebTransport deferred |
|
||||
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by ADR-044 §5) | Bearer token, no `PeerId`, connection-local overlay (addressability vs. bidirectionality) |
|
||||
| Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_<status>` prefix for imported |
|
||||
| Custom HTTP routes from the assembly layer | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | `extra_routes: Option<Router>` at construction; raw HTTP, not operations; default surface takes precedence on collision |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-39** (open): `to_openapi` published-spec versioning — the
|
||||
generated OpenAPI spec is a compatibility contract (ADR-017
|
||||
Consequences); the versioning strategy needs specifying.
|
||||
- **OQ-39** (resolved): `to_openapi` published-spec versioning —
|
||||
resolved by [ADR-045](../../decisions/045-to-openapi-gateway-spec-versioning.md):
|
||||
`info.version` semver tracks the gateway endpoint contract (not the
|
||||
operation set); the per-caller operation surface is discovered via
|
||||
`/search` and does not bump the version.
|
||||
- **OQ-40** (resolved): reqwest client config and connection pooling —
|
||||
`ClientWithMiddleware` + middleware stack; the outbound HTTP client
|
||||
used by `from_openapi`/`from_mcp`.
|
||||
@@ -406,10 +496,20 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
— WebSocket is the v1 browser bidirectional path; `h3`/WebTransport
|
||||
deferred. States the "browser is not a peer" rationale (addressability
|
||||
vs. bidirectionality) that ADR-034 §4 closes without arguing.
|
||||
References the `@alkdev/pubsub` WebSocket prior art (the
|
||||
`EventEnvelope { type, id, payload }` client/server the call
|
||||
protocol's envelope was derived from).
|
||||
- [overview.md](overview.md) — crate overview, adapter location map
|
||||
- [webtransport.md](webtransport.md) — the deferred `h3` ALPN handler
|
||||
(kept intact for revival)
|
||||
- [http-adapters.md](http-adapters.md) — `from_openapi`/`to_openapi`
|
||||
- [../call/call-protocol.md](../call/call-protocol.md) — `EventEnvelope`
|
||||
wire format, `Dispatcher` (stream-agnostic; runs over WS unchanged),
|
||||
the `@alkdev/pubsub` prior-art note
|
||||
- `/workspace/@alkdev/pubsub/src/event-target-websocket-client.ts`,
|
||||
`/workspace/@alkdev/pubsub/src/event-target-websocket-server.ts` —
|
||||
TypeScript prior art for the WS browser path (the
|
||||
`EventEnvelope { type, id, payload }` over WS binary messages)
|
||||
- [../core/auth.md](../core/auth.md) — `IdentityProvider`, Bearer →
|
||||
`resolve_from_token`
|
||||
- [../core/endpoint.md](../core/endpoint.md) — stealth mode as ALPN
|
||||
|
||||
@@ -241,14 +241,15 @@ verified against this invariant. See ADR-014 and
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| HTTP-to-call operation mapping | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | Direct path mapping; `to_openapi` is projection, not router |
|
||||
| HTTP-to-call operation mapping | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | ~~Direct path mapping~~ — **routing superseded by ADR-047**; gateway `/call` is the sole invoke path; ADR-036's non-routing clauses survive (SSE, auth, `/healthz`, stealth, error mapping) |
|
||||
| MCP stdio transport exclusion | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built (RCE vector) |
|
||||
| Defer h3/WebTransport; browsers use WebSocket | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | `h3`/WebTransport deferred (scope, not hedging); browser bidirectional path uses WebSocket; ADR-038 superseded, ADR-040/043 parked |
|
||||
| HTTP server + client host colocated | [ADR-039](../../decisions/039-http-server-and-client-host-colocated.md) | One crate for server + adapters (shared HTTP deps, shared mapping) |
|
||||
| ~~HTTP/3 + WebTransport first-class~~ | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | **Superseded by ADR-044** (anti-pattern correction stands; specific decision reversed) |
|
||||
| ~~WebTransport ALPN-stream-proxy~~ | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | **Parked** per ADR-044; revives unchanged when WebTransport revives |
|
||||
| `to_mcp` tool-gateway pattern | [ADR-041](../../decisions/041-mcp-tool-gateway-pattern.md) | 4 fixed gateway tools (search/schema/call/batch), not one tool per operation |
|
||||
| `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | 5 fixed gateway endpoints (search/schema/call/batch/subscribe); per-caller AccessControl-filtered |
|
||||
| `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md), [ADR-047](../../decisions/047-remove-direct-call-http-surface.md) | 5 fixed gateway endpoints are the sole HTTP invoke path (no per-operation `POST /{service}/{op}`); per-caller AccessControl-filtered `/search` is the discovery |
|
||||
| Assembly-layer custom HTTP routes | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | `extra_routes: Option<Router>` at construction; deployments add raw HTTP endpoints (e.g., OAI-compatible proxy, or a REST-like per-operation projection) that coexist with the default surface; default surface takes precedence on collision |
|
||||
| ~~WebTransport bidirectional ALPN substrate~~ | [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md) | **Parked** per ADR-044; §2/§3 transfer to WebSocket for v1; §4/§5 revive with WebTransport |
|
||||
| `alknet-call` is protocol-foundation | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | `alknet-http` depends on `alknet-call` (types, not peer handler) |
|
||||
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source + resolution (settled) |
|
||||
@@ -272,8 +273,10 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
mixed-fingerprint `PeerEntry`.
|
||||
- **OQ-38** (open, scope): WebTransport relay-as-proxy — does the proxy
|
||||
live in `alknet-http` or a separate relay crate?
|
||||
- **OQ-39** (open): `to_openapi` published-spec versioning — versioning
|
||||
strategy for generated OpenAPI specs.
|
||||
- **OQ-39** (resolved): `to_openapi` published-spec versioning —
|
||||
`info.version` semver tracks the gateway endpoint contract, not the
|
||||
operation set (ADR-045); per-caller operations discovered via
|
||||
`/search`.
|
||||
- **OQ-40** (resolved): reqwest client config and connection pooling —
|
||||
`ClientWithMiddleware` + middleware stack (retry + Retry-After).
|
||||
|
||||
@@ -284,6 +287,14 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
location map, no-env-vars invariant
|
||||
- `/workspace/@alkdev/operations/src/from_openapi.ts`,
|
||||
`/workspace/@alkdev/operations/src/from_mcp.ts` — TypeScript prior art
|
||||
for the HTTP adapters (the SSE normalization, auth-header, and
|
||||
`createHTTPOperation` patterns)
|
||||
- `/workspace/@alkdev/pubsub/src/event-target-websocket-client.ts`,
|
||||
`/workspace/@alkdev/pubsub/src/event-target-websocket-server.ts` —
|
||||
TypeScript prior art for the WebSocket browser path (the
|
||||
`EventEnvelope { type, id, payload }` over WS binary messages; the
|
||||
call protocol's envelope is a refined superset — see ADR-044
|
||||
§"Concrete prior art")
|
||||
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP examples
|
||||
- `/workspace/wtransport/` — pure-Rust WebTransport reference
|
||||
(read during research; not a dependency — see ADR-044 §"Research note"
|
||||
|
||||
@@ -59,13 +59,13 @@ enabled. It serves two things on a single `h3` connection:
|
||||
|
||||
1. **HTTP/3 requests** — the standard HTTP/3 over QUIC framing. An
|
||||
HTTP/3 request is dispatched through the same axum `Router` as `h2`/
|
||||
`http/1.1` requests (ADR-036 — the HTTP path IS the operation path
|
||||
on the direct-call surface; ADR-042 — the gateway endpoints). From
|
||||
the axum router's perspective, an HTTP/3 request is just
|
||||
another HTTP request; the framing difference is handled below the
|
||||
router. The HTTP/3 request path is the **one-directional projection**
|
||||
(client→server calls only — HTTP is request/response; see
|
||||
[http-server.md](http-server.md) §"One-directional projection").
|
||||
`http/1.1` requests (ADR-042 + ADR-047 — the gateway endpoints are
|
||||
the sole invoke path; the direct-call `POST /{service}/{op}` surface
|
||||
was removed). From the axum router's perspective, an HTTP/3 request
|
||||
is just another HTTP request; the framing difference is handled
|
||||
below the router. The HTTP/3 request path is the **one-directional
|
||||
projection** (client→server calls only — HTTP is request/response;
|
||||
see [http-server.md](http-server.md) §"One-directional projection").
|
||||
2. **WebTransport sessions** — the **bidirectional** path. WebTransport
|
||||
is a transport substrate that carries ALPN protocols as
|
||||
bidirectional streams (ADR-043), not a browser→hub one-way path. A
|
||||
|
||||
Reference in New Issue
Block a user