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:
2026-06-30 09:49:25 +00:00
parent 3327d585da
commit 2a6e4c371a
14 changed files with 1082 additions and 129 deletions

View File

@@ -18,7 +18,7 @@ The storage and auth strategy research (`docs/research/alknet-storage-strategy/f
The alknet-call crate is **implemented and reviewed** — both the server-side core and the client/adapter surface (207 lib + 2 integration tests passing). The alknet-core and alknet-call crate specs are in draft; the alknet-vault crate specs are stable.
**alknet-http specs drafted and consistency-reviewed.** The alknet-http crate (HTTP interface — `h2`/`http/1.1` server + WebSocket browser path + `from_openapi`/`to_openapi`/`from_mcp`/`to_mcp` adapters) now has architecture specs: [crates/http/](crates/http/) (overview, http-server, http-adapters, http-mcp, webtransport) and nine ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping; direct-call surface), [ADR-037](decisions/037-mcp-stdio-transport-exclusion.md) (MCP stdio exclusion), [ADR-038](decisions/038-http3-and-webtransport-as-first-class.md) (HTTP/3 + WebTransport as first-class — **superseded by ADR-044**; its correction of the two-way-door-as-deferral anti-pattern stands, its specific decision is reversed by the scope deferral), [ADR-039](decisions/039-http-server-and-client-host-colocated.md) (HTTP server + client host colocated in one crate), [ADR-040](decisions/040-webtransport-alpn-stream-proxy.md) (WebTransport ALPN-stream-proxy — **parked** per ADR-044; revives unchanged when WebTransport revives), [ADR-041](decisions/041-mcp-tool-gateway-pattern.md) (`to_mcp` tool-gateway pattern — 4 fixed gateway tools instead of one tool per operation, addressing LLM context tool-bloat), [ADR-042](decisions/042-openapi-gateway-pattern.md) (`to_openapi` gateway pattern — 5 fixed gateway endpoints instead of one path per operation; per-caller AccessControl-filtered API surface; supersedes ADR-036's original `to_openapi` clause), [ADR-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), [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md) (defer `h3`/WebTransport; browsers use WebSocket for the bidirectional call-protocol path; a scope decision per ADR-009 §"What this framework is NOT"; reversal trigger = a concrete ALPN-stream-proxy use case; states the "browser is not a peer" rationale — addressability vs. bidirectionality — that amends ADR-034 §4). ADR-003 Amendment 1 clarifies that `alknet-call` is a protocol-foundation crate (the `alknet-http``alknet-call` dependency edge). A consistency review pass corrected drift from the mid-spec pivot (the `to_openapi` gateway pattern landed in the prose but not in cross-references; the WebTransport specs inherited the OpenAPI/MCP direction assumption that doesn't hold for the call protocol) — ADR-036's `to_openapi` clause is now amended as superseded by ADR-042, ADR-034 §5's "deferral bucket" wording is corrected (the decision stands), and the http specs now name the one-directional HTTP projection vs. the bidirectional WebSocket (and, when revived, WebTransport) substrate. The specs are in draft; implementation has not started. Three open questions carried: OQ-38 (WebTransport standalone relay service scope — distinct from the in-process ALPN-stream-proxy resolved by ADR-040), OQ-39 (`to_openapi` published-spec versioning), OQ-40 (reqwest client config).
**alknet-http specs drafted and consistency-reviewed.** The alknet-http crate (HTTP interface — `h2`/`http/1.1` server + WebSocket browser path + `from_openapi`/`to_openapi`/`from_mcp`/`to_mcp` adapters) now has architecture specs: [crates/http/](crates/http/) (overview, http-server, http-adapters, http-mcp, webtransport) and twelve ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping; direct-call surface**routing superseded by ADR-047**, non-routing clauses survive), [ADR-037](decisions/037-mcp-stdio-transport-exclusion.md) (MCP stdio exclusion), [ADR-038](decisions/038-http3-and-webtransport-as-first-class.md) (HTTP/3 + WebTransport as first-class — **superseded by ADR-044**; its correction of the two-way-door-as-deferral anti-pattern stands, its specific decision is reversed by the scope deferral), [ADR-039](decisions/039-http-server-and-client-host-colocated.md) (HTTP server + client host colocated in one crate), [ADR-040](decisions/040-webtransport-alpn-stream-proxy.md) (WebTransport ALPN-stream-proxy — **parked** per ADR-044; revives unchanged when WebTransport revives), [ADR-041](decisions/041-mcp-tool-gateway-pattern.md) (`to_mcp` tool-gateway pattern — 4 fixed gateway tools instead of one tool per operation, addressing LLM context tool-bloat), [ADR-042](decisions/042-openapi-gateway-pattern.md) (`to_openapi` gateway pattern — 5 fixed gateway endpoints instead of one path per operation; per-caller AccessControl-filtered API surface; supersedes ADR-036's original `to_openapi` clause), [ADR-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), [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md) (defer `h3`/WebTransport; browsers use WebSocket for the bidirectional call-protocol path; a scope decision per ADR-009 §"What this framework is NOT"; reversal trigger = a concrete ALPN-stream-proxy use case; states the "browser is not a peer" rationale — addressability vs. bidirectionality — that amends ADR-034 §4), and [ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md) (`to_openapi` published-spec versioning — `info.version` semver tracks the gateway endpoint contract, not the operation set; resolves OQ-39), and [ADR-046](decisions/046-assembly-layer-custom-http-routes.md) (assembly-layer custom HTTP routes on HttpAdapter — `extra_routes: Option<Router>` for deployment-specific endpoints like an OAI-compatible proxy; default surface unchanged, takes precedence on collision), and [ADR-047](decisions/047-remove-direct-call-http-surface.md) (remove the direct-call `POST /{service}/{op}` surface — the gateway `/call` is the sole invoke path; the simplified contract is the few-fixed-endpoints model, not a per-operation REST tree; ADR-036's non-routing clauses survive). ADR-003 Amendment 1 clarifies that `alknet-call` is a protocol-foundation crate (the `alknet-http``alknet-call` dependency edge). A consistency review pass corrected drift from the mid-spec pivot (the `to_openapi` gateway pattern landed in the prose but not in cross-references; the WebTransport specs inherited the OpenAPI/MCP direction assumption that doesn't hold for the call protocol) — ADR-036's `to_openapi` clause is now amended as superseded by ADR-042, ADR-034 §5's "deferral bucket" wording is corrected (the decision stands), and the http specs now name the one-directional HTTP projection vs. the bidirectional WebSocket (and, when revived, WebTransport) substrate. The specs are in draft; implementation has not started. Two open questions carried: OQ-38 (WebTransport standalone relay service scope — distinct from the in-process ALPN-stream-proxy resolved by ADR-040) and OQ-40 (reqwest client config — since resolved by the `ClientWithMiddleware` + middleware stack design). OQ-39 (`to_openapi` published-spec versioning) is resolved by ADR-045.
**Next step**: The storage/repo-pattern ADRs (030033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http implementation (specs drafted; `h3`/WebTransport deferred per ADR-044, browser bidirectional path uses WebSocket), which consumes the `CredentialStore` trait and the `OperationAdapter` contract. The alknet-ssh crate (the other post-core crate, specced in parallel) proceeds independently — it depends on `alknet-core`, not `alknet-call`.
@@ -88,7 +88,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Accepted |
| [034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Outgoing-Only X.509 and the Three Peer Roles | Accepted |
| [035](decisions/035-concrete-persistence-adapter-shapes.md) | Concrete Persistence Adapter Shapes — Read/Write Split, honker+SQLite | Accepted |
| [036](decisions/036-http-to-call-operation-mapping.md) | HTTP-to-Call Operation Mapping | Proposed |
| [036](decisions/036-http-to-call-operation-mapping.md) | HTTP-to-Call Operation Mapping | Proposed**routing decision superseded by ADR-047** (non-routing clauses survive: SSE, auth, `/healthz`, stealth, error mapping) |
| [037](decisions/037-mcp-stdio-transport-exclusion.md) | MCP Stdio Transport Exclusion | Proposed |
| [038](decisions/038-http3-and-webtransport-as-first-class.md) | HTTP/3 and WebTransport as First-Class HTTP Transports | ~~Proposed~~**Superseded** by ADR-044 |
| [039](decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | Proposed |
@@ -97,6 +97,9 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [042](decisions/042-openapi-gateway-pattern.md) | OpenAPI Gateway Pattern for to_openapi | Proposed |
| [043](decisions/043-webtransport-bidirectional-alpn-substrate.md) | WebTransport as a Bidirectional ALPN Transport Substrate | Proposed — **parked** (implementation deferred per ADR-044; §2/§3 transfer to WebSocket) |
| [044](decisions/044-defer-webtransport-browsers-use-websocket.md) | Defer h3/WebTransport; Browsers Use WebSocket | Accepted |
| [045](decisions/045-to-openapi-gateway-spec-versioning.md) | to_openapi Gateway-Spec Versioning | Proposed |
| [046](decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | Proposed |
| [047](decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | Proposed |
## Open Questions
@@ -146,7 +149,7 @@ See [open-questions.md](open-questions.md) for the full tracker.
- **OQ-36**: ~~Concrete persistence adapter shapes~~**resolved by ADR-035** (read-sync / write-async / honker-NOTIFY cache invalidation; `alknet-store-sqlite` crate; `IdentityStore` write trait; `CredentialStore::put`/`delete` async)
- **OQ-37**: ~~X.509 outgoing-only case~~**resolved by ADR-034** (three remote roles named: public X.509 endpoint, transport relay, hub; `PeerEntry` asymmetry is correct; client-side verifier selection by `PeerEntry` presence)
- **OQ-38**: WebTransport standalone relay service scope — the standalone relay (future `alknet-relay`, fork of iroh-relay with WebTransport proxy fallback) is distinct from the in-process ALPN-stream-proxy (ADR-040); scope question, not deferral
- **OQ-39**: `to_openapi` published-spec versioning — versioning strategy for generated OpenAPI specs (one-way after first publication)
- **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`)
**Deferred (not active):**
- **OQ-09**: WASM target boundaries — design constraint, not deliverable

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -2,7 +2,13 @@
## Status
Proposed
Proposed — **routing decision superseded by
[ADR-047](047-remove-direct-call-http-surface.md)** (the direct-call
surface `POST /{service}/{op}` is removed; the gateway `/call` is the
sole invoke path). ADR-036's other clauses — SSE projection, Bearer
auth, `/healthz`, stealth decoy, error mapping, `External`-only
dispatch — remain in force (see ADR-047 §"What survives from
ADR-036"). The `to_openapi` clause was already superseded by ADR-042.
## Context
@@ -42,6 +48,15 @@ routing table that has to be kept in sync with the registry.
## Decision
> **Routing decision superseded by
> [ADR-047](047-remove-direct-call-http-surface.md).** The direct-call
> surface defined below (`POST /{service}/{op}` → `call.requested`) is
> removed — the gateway's `/call` endpoint (ADR-042) is the sole invoke
> path over HTTP. This section is retained as the historical record of
> the original decision; ADR-047 records the reversal and what survives.
> The `to_openapi` clause below was already superseded by ADR-042 (see
> the amendment in this section).
**Direct path mapping is the default HTTP surface; `to_openapi` is the
discovery/projection layer, not a parallel router.**
@@ -68,10 +83,16 @@ serves; there is no second mapping.
> table" property is preserved (the gateway endpoints are fixed; the
> per-caller operation surface is discovered via `/search`, not preloaded
> into a generated path set). The direct-call surface (`POST
> /{service}/{op}`) that this ADR defines is **unchanged** — ADR-042 only
> changes what `to_openapi` *describes*, not what the HTTP handler
> *serves*. A traditional per-operation-paths OpenAPI projection remains
> available as an additive alternative (ADR-042 §5).
> /{service}/{op}`) that this ADR defines was **unchanged at the time**
> — ADR-042 only changed what `to_openapi` *describes*, not what the
> HTTP handler *serves*. **The direct-call surface was later removed by
> [ADR-047](047-remove-direct-call-http-surface.md)** (the gateway
> `/call` is the sole invoke path; the simplified contract is a few
> fixed endpoints, not a per-operation REST tree). A traditional
> per-operation-paths OpenAPI projection remains available as an
> additive alternative (ADR-042 §5), and a deployment that wants the
> former direct-call HTTP surface builds it as a custom route
> projection (ADR-046).
### HTTP method semantics
@@ -212,7 +233,11 @@ without auth before identity is resolvable.
- [ADR-042](042-openapi-gateway-pattern.md) — supersedes this ADR's
`to_openapi` clause (the per-operation-paths projection is replaced by
the 5-endpoint gateway pattern; the direct-call surface this ADR
defines is unchanged)
defines is unchanged*at the time*; ADR-047 later removes it)
- [ADR-047](047-remove-direct-call-http-surface.md) — supersedes this
ADR's routing decision (the direct-call surface is removed; the
gateway `/call` is the sole invoke path). This ADR's non-routing
clauses survive.
- OQ-13 (resolved) — operation path format `/{service}/{op}`
- `docs/research/alknet-http/phase-0-findings.md` DH-3 — the decision this
ADR resolves

View File

@@ -130,10 +130,11 @@ Once published, the gateway endpoint set (5 endpoints) and the
request/response shapes are a compatibility contract (ADR-017
Consequences). Adding endpoints is additive (non-breaking); removing or
renaming is a one-way door. The initial 5-endpoint set is the published
contract. The versioning strategy for the generated doc is tracked as
OQ-39 (same as the per-operation-paths versioning question — the
gateway pattern doesn't eliminate the versioning concern, it simplifies
it to 5 stable endpoints instead of a per-operation surface).
contract. The versioning strategy for the generated doc was tracked as
OQ-39 (now **resolved by [ADR-045](045-to-openapi-gateway-spec-versioning.md)**:
`info.version` semver tracks the gateway endpoint contract, not the
operation set) — the gateway pattern simplifies versioning to 5 stable
endpoints instead of a per-operation surface.
### 5. A traditional per-operation-paths projection is additive, not replacement
@@ -244,5 +245,6 @@ require it for the common case.
pattern for `to_mcp` (4 tools; `subscribe` excluded because MCP tool
calls are request/response)
- OQ-39 — `to_openapi` published-spec versioning (simplified by the
gateway pattern to 5 stable endpoints)
gateway pattern to 5 stable endpoints; **resolved by
[ADR-045](045-to-openapi-gateway-spec-versioning.md)**)
- `crates/http/http-adapters.md` — the spec that implements the gateway

View File

@@ -98,6 +98,61 @@ protocol bidirectionally," their answer was WSS, not WebTransport. Aligning
with that precedent is not cutting against competent practice — it is
matching it.
### Concrete prior art: `@alkdev/pubsub`
The WebSocket path is not speculative — there is working prior art in the
same workspace. The `@alkdev/pubsub` package (`/workspace/@alkdev/pubsub/`)
already has a WebSocket client (`event-target-websocket-client.ts`) and
server (`event-target-websocket-server.ts`) built on a generalized "event
target" abstraction with an `EventEnvelope { type, id, payload }` shape.
The alknet call protocol's `EventEnvelope` was derived from this envelope
(refined with typed event names `call.requested`/`call.responded`/etc. and
structured payloads); the sibling `@alkdev/operations` package
(`/workspace/@alkdev/operations/`) shares the lineage and uses the
`path.do.op` (dot-separated) vs alknet's `path/to/op` (slash-separated)
convention — a minor, mechanical delta. Syncing the pubsub/operations
WebSocket client to the alknet call protocol's envelope is a small adjustment
(~a day of work: the envelope shape, the event-name typing, the path
separator), not a from-scratch browser-client build. This is why the
WebSocket path opens doors quickly: the browser (and Node) client is
mostly already written.
### The tradeoff between two use cases, not "good enough for now"
It is worth being precise about *why* WSS is the right choice here, because
"good enough until it isn't" undersells the decision. The two browser-reach
use cases have different right tools:
- **The call protocol from a browser (bidirectional).** WSS is *genuinely
the right tool*, not a stopgap. The call protocol multiplexes by request
ID (ADR-012), not by stream — it does not need WebTransport's per-stream
multiplexing. A WebSocket is a full-duplex, long-lived, framed-message
channel; the call protocol's `EventEnvelope` framing fits a WS binary
message cleanly (one envelope = one message). For this use case,
WebTransport's stream model is engineering sophistication the call protocol
has no use for. WSS is not "good enough" — it is well-matched.
- **The generalized ALPN router/proxy (a browser reaching a non-call ALPN
— SSH/SFTP/git via WASM).** WebTransport's native multi-stream model is
*genuinely the right tool* here, and WSS is *probably worse* for it. A
browser reaching a non-call ALPN over WSS would have to multiplex logical
streams over one WS frame stream by application-level framing — doable
(ADR-043 §"SSH/SFTP/git-over-WSS-from-a-browser is technically possible"),
but it re-implements at the application layer what WebTransport gives at
the transport layer. This is the use case WebTransport was built for, and
it is the speculative one (Finding 3) — the consumers (WASM SSH/SFTP/git
parsers) do not exist yet.
So the deferral is not "use the worse tool now, upgrade to the better tool
later." It is "use the right tool for the use case we *have* (call protocol
from a browser → WSS), and defer building the tool for the use case we
*don't have yet* (generalized ALPN proxy → WebTransport)." When WebTransport
arrives, the two coexist (§Reversal point 3): WSS stays as the simpler
call-protocol path; WebTransport adds the ALPN-stream-proxy path. Neither
replaces the other. This is "good enough is good enough until it isn't" in
the precise sense: WSS is good enough for the call-protocol case *because
it is the right tool*, and the case where WebTransport would be better is
a case we don't have yet.
## Decision
### 1. Defer `h3`/WebTransport. Browsers reach the call protocol over WebSocket.
@@ -241,9 +296,12 @@ by reference to this section.
is a real deployment needing it.
- WebSocket is a single stream; it lacks WebTransport's native multi-stream
multiplexing. For the call protocol this is fine (correlation is by request
ID, not by stream — ADR-012), but it means a future migration to
WebTransport would be a genuine upgrade, not a no-op. The migration path
is the spec that already exists (`webtransport.md`).
ID, not by stream — ADR-012), and WSS is the well-matched tool for that use
case (see §"The tradeoff between two use cases"). Where WebTransport's
stream model would matter is the ALPN-stream-proxy (ADR-040) — the
speculative use case whose deferral this ADR commits. The migration path
is the spec that already exists (`webtransport.md`), and when WebTransport
arrives it coexists with WSS rather than replacing it.
- ADR-043's "WebTransport restores bidirectionality" framing (§5) becomes
"WebSocket restores bidirectionality" for v1. The framing transfer is clean
(§3 above), but the prose in `http-server.md` and the ADRs must reflect it.
@@ -290,11 +348,15 @@ from scratch. See `webtransport.md` §"Research note" for the cross-reference.
1. **The call protocol's `EventEnvelope` framing fits a WebSocket binary
message boundary cleanly.** An `EventEnvelope` is a self-delimited JSON
object; one envelope per WS binary message. No streaming deserializer
across message boundaries is needed. This is verified by implementation
when the WS browser path is built, not by a separate research spike — the
call protocol spec (`call-protocol.md`) and the EventEnvelope shape
already make this property clear, and WebSocket binary messages are a
standard byte-framed transport.
across message boundaries is needed. This is already verified by prior
art: the `@alkdev/pubsub` WebSocket client/server
(`/workspace/@alkdev/pubsub/src/event-target-websocket-client.ts`,
`event-target-websocket-server.ts`) carries the same
`{ type, id, payload }` envelope over WS binary messages — the alknet
`EventEnvelope` is a refined superset of that shape (typed event names,
structured payloads). The call protocol spec (`call-protocol.md`) and
the EventEnvelope shape make the property clear, and the pubsub prior
art demonstrates it concretely.
2. **WebSocket upgrade over HTTP/1.1 or HTTP/2 is supported by the axum/
hyper stack natively.** `axum::extract::ws` provides the upgrade handler;

View File

@@ -0,0 +1,177 @@
# ADR-045: to_openapi Gateway-Spec Versioning
## Status
Proposed
## Context
OQ-39 asked how the published `to_openapi` spec is versioned. ADR-017
Consequences established that a published `to_*` spec is a compatibility
contract: once external clients build against it, the mapping semantics
become a de facto contract and changing them breaks every client.
The original framing of OQ-39 assumed `to_openapi` generated a
traditional per-operation-paths OpenAPI doc — one path per `External`
operation, changing whenever an operation is added, removed, or has its
schema modified. Under that model the versioning surface is large and
churns constantly, and the doc is a static full-surface dump (the Gitea
failure mode: admin ops shown to every caller, no per-caller filtering).
ADR-042 replaced that model with the **gateway pattern**: `to_openapi`
generates a doc describing **5 fixed gateway endpoints**
(`/search`, `/schema`, `/call`, `/batch`, `/subscribe`), and the
per-caller operation surface is discovered at runtime through
`AccessControl`-filtered `/search` — not preloaded into the static doc.
This is the same mechanic as the MCP gateway (ADR-041), with `subscribe`
added because OpenAPI/SSE supports streaming where MCP tool calls are
request/response.
The consequence for versioning: the published doc is now a small, stable
surface that changes only when the gateway endpoint set or an endpoint's
request/response shape changes. Per-caller operation changes
(adding/removing/modifying operations, changing an operation's schema)
do **not** change the published doc — those operations are not in the
doc; they are discovered via `/search`. This dissolves most of the
churn the original OQ-39 was concerned about.
What remains is the narrow versioning question: how does the published
gateway doc signal its version so consumers can detect breaking changes?
This is one-way after first publication — once external clients build
against the gateway doc, renaming `/call` or changing its request shape
breaks them.
A note on door-type framing: ADR-009 classifies doors by reversal cost
in the codebase. The "published artifact is a contract" case is a blind
spot in that framework — the published doc's reversal cost is paid by
external consumers, not in the codebase. ADR-017 Consequences captures
this (published `to_*` specs are compatibility contracts); this ADR
honors the constraint without changing ADR-009's framework. The door is
two-way before first publication (the gateway shape can be revised
freely while no external client depends on it) and one-way after
(revising requires a major version bump that signals breakage to
consumers).
## Decision
### 1. The published gateway doc carries a semver `info.version`
`to_openapi` emits `info.version` as a semver string. The version
reflects the **gateway endpoint contract** (the 5 endpoints + their
request/response shapes), not the operation set:
- **Major bump** — breaking change to the gateway contract: an endpoint
removed or renamed, a required field added to a gateway endpoint's
request, a response shape changed in a backward-incompatible way
(including removing or retyping an existing response field, or
tightening an optional field to required),
the error-mapping semantics (ADR-023) changed.
- **Minor bump** — additive change: a new gateway endpoint added
(e.g., a future `/subscribe-batch`), a new optional request field, a
new response field. Additive changes do not break existing clients.
- **Patch bump** — description/wording changes, documentation, no shape
change.
Cases not enumerated above follow **standard semver**: a change is a
major bump if it could break a client built against the prior version,
a minor bump if it is purely additive, a patch bump otherwise. The
enumerated triggers above are the common cases, not an exhaustive list.
Per-caller operation changes (registering a new operation, removing one,
changing an operation's input schema) **do not bump the version** — the
operation set is not part of the published doc; it is discovered via
`/search` at runtime. This is the key simplification the gateway pattern
buys: the operation surface can evolve freely without touching the
published contract version.
### 2. The version is bumped on change to the gateway shape, not on regeneration
A deployment that regenerates the doc (e.g., on restart) gets the same
`info.version` unless the gateway shape changed. The version is a
function of the gateway contract, not of when the doc was generated.
### 3. Consumers detect breaking changes via the major version
A client reading the doc compares `info.version`'s major component to
the version it built against. A major bump signals "re-read the doc,
something broke." The minor/patch components are informational. This is
the standard OpenAPI/semver convention — no alknet-specific detection
mechanism.
### 4. The traditional per-operation-paths projection (additive, ADR-042 §5) versions independently
A deployment that builds the additive traditional REST projection
(ADR-042 §5) versions that doc on its own schedule — its surface
*does* change with the operation set, so its versioning is the
per-operation churn OQ-39 originally worried about. That projection is
opt-in and out of scope for this ADR; the gateway doc is the default
published contract and the one this ADR governs.
## Consequences
**Positive:**
- The published contract is a 5-endpoint surface that rarely changes.
Versioning is bump-on-change, not bump-on-every-operation-change. The
original OQ-39 concern (constant churn) is dissolved by the gateway
pattern — the operation set is not in the doc.
- Consumers use standard semver/OpenAPI `info.version` — no
alknet-specific version-detection mechanism to learn.
- Per-caller operation evolution (the common case) is decoupled from the
published-contract version. A node can add/remove operations freely
without bumping the doc version or breaking clients built against the
gateway doc.
- The Gitea failure mode stays structurally impossible (ADR-042 §3):
`/search` is `AccessControl`-filtered, so the doc never exposes ops
the caller can't call. Versioning inherits this — the doc describes
the gateway, not the operations.
**Negative:**
- A client cannot tell from the doc version alone *which* operations are
available — it must call `/search`. This is by design (per-caller,
runtime), but a client expecting a static operation list from the doc
must learn the gateway pattern.
- The version only signals gateway-contract changes. An operation
changing its input schema (a breaking change for callers of that
operation) does not bump the doc version — that change is surfaced via
`/schema` per-operation, not via the doc version. Clients that cache
operation schemas must re-fetch `/schema` to detect per-operation
changes; the doc version does not track them.
## Assumptions
1. **The 5-endpoint gateway set is stable.** ADR-042 Assumption 1. Adding
endpoints is additive (minor bump); removing/renaming is a major bump.
The initial 5-endpoint set is the first published contract.
2. **Per-operation schema changes are detected via `/schema`, not the
doc version.** The doc version tracks the gateway contract only. A
client that caches an operation's `OperationSpec` re-fetches `/schema`
to detect changes to that operation. This is the standard
discovery-then-invoke pattern; the doc version is not a per-operation
change tracker.
3. **`info.version` is the single source of truth for the published
contract version.** No separate `x-alknet-version` extension or
content-hash header. Standard OpenAPI field, standard semver
interpretation. A content-hash would be more precise but adds an
alknet-specific mechanism for no real gain over semver-on-shape-
change.
## References
- [ADR-009](009-one-way-door-decision-framework.md) — door-type
framework (classifies by codebase reversal cost; the
published-artifact-as-contract case is the blind spot this ADR honors
without changing the framework)
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — published
`to_*` specs are compatibility contracts (the one-way-after-
publication constraint)
- [ADR-023](023-operation-error-schemas.md) — error-mapping semantics
are part of the gateway contract (a change to them is a major bump)
- [ADR-036](036-http-to-call-operation-mapping.md) — the SSE projection
for `/subscribe` (part of the gateway contract)
- [ADR-042](042-openapi-gateway-pattern.md) — the gateway pattern that
makes the published doc a 5-endpoint surface instead of a per-
operation surface; §4 explicitly deferred versioning to OQ-39
- OQ-39 — `to_openapi` published-spec versioning (resolved by this ADR)
- `crates/http/http-adapters.md` — the spec that emits `info.version`

View File

@@ -0,0 +1,248 @@
# ADR-046: Assembly-Layer Custom HTTP Routes on HttpAdapter
## Status
Proposed
## Context
The `HttpAdapter` (`crates/http/http-server.md`) is constructed by the
assembly layer with an `Arc<dyn IdentityProvider>`, an
`Arc<OperationRegistry>`, and a `DecoyConfig`. The axum `Router` it
builds has a fixed surface:
- The `to_openapi` gateway endpoints (`/search`, `/schema`, `/call`,
`/batch`, `/subscribe` — ADR-042) — the sole invoke path over HTTP
(ADR-047 removed the direct-call `POST /{service}/{op}` surface that
ADR-036 originally defined).
- `/healthz`, `/openapi.json`, the MCP route (feature-gated), and the
decoy fallback for unknown paths.
There is no documented extension point for a downstream deployment to
add its own HTTP routes to this router. A deployment that wants to
expose a custom HTTP endpoint — one that is *not* a gateway endpoint —
has no specified way to do so. The architecture currently ties the HTTP
surface to the simplified contract with no escape hatch.
### The concrete use case
A hub deployment (e.g., `api.alk.dev`) wants to expose the standard
alknet contract (direct-call + gateway) **and** an OpenAI-compatible
proxy at `/v1/chat/completions`. The OAI proxy is a custom HTTP route:
it receives an OpenAI-shaped request, dispatches into the
`OperationRegistry` (likely to a `from_openapi`-imported `openai/chat`
operation or a custom agent operation), and returns an OpenAI-shaped
response. It is not an alknet operation — it is a deployment-specific
HTTP endpoint that uses the registry as a backend.
This pattern is not exotic. It is the standard "wrap an external API
shape around our operations" pattern: a deployment adds a
compatibility shim (OAI-compatible, Anthropic-compatible, a legacy API
shape) as a custom route, backed by call-protocol operations. The
alternative — forcing every custom endpoint to be a call-protocol
operation whose input/output match the external API's shape — is
brittle (the OAI streaming response shape is not a clean call-protocol
output) and unnecessary (the deployment owns the HTTP shape; the
registry owns the operation shape).
The runner pattern that motivates this (remote GPU instance downloads
a binary, connects back to the hub via `from_call`, registers its ops;
opencode connects to the hub as a standard OAI provider) is already
supported by the existing architecture (`from_call`, `PeerRef` routing,
`from_openapi` to wrap the OAI API). The only missing piece is the
custom HTTP route on the hub.
### Why this needs an ADR
The extension mechanism — how the assembly layer injects custom routes
— is a published API surface of `HttpAdapter`. Once downstream
deployments build against it (passing their custom routers at
construction), changing the mechanism is a one-way door (every consumer
construction site breaks). It needs an ADR before implementation so the
contract is deliberate, not accidental.
The specific routes a deployment adds are a two-way door (add/remove
freely, no protocol contract). The *mechanism* (the constructor
parameter and its semantics) is the one-way door this ADR commits.
## Decision
### 1. HttpAdapter accepts additional axum routes from the assembly layer
The `HttpAdapter` constructor gains a parameter for deployment-specific
routes. The assembly layer builds an `axum::Router` with its custom
routes and passes it in; `HttpAdapter` composes it with the default
surface. A deployment that passes no custom routes gets exactly the
documented default behavior — the extension point is additive, not
mandatory.
```rust
pub struct HttpAdapter {
identity_provider: Arc<dyn IdentityProvider>,
registry: Arc<OperationRegistry>,
decoy: DecoyConfig,
/// Deployment-specific routes added by the assembly layer. None =
/// the default surface only. See ADR-046.
extra_routes: Option<Router>,
}
```
The exact composition mechanism (merge vs nest vs builder, whether
custom routes get a prefix) is a two-way-door implementation detail;
the one-way constraint is that the assembly layer can inject routes
and they coexist with the default surface. axum's `Router::merge` /
`Router::nest` are the natural primitives.
### 2. Custom routes are raw HTTP, not call-protocol operations
A custom route is a raw axum handler — it receives an HTTP request and
returns an HTTP response. It is not registered in the
`OperationRegistry`, not discoverable via `/search`, not described in
the `to_openapi` gateway doc. The deployment owns its shape entirely.
A custom route *may* dispatch into the `OperationRegistry` (via
`OperationRegistry::invoke()`, same as the gateway's `/call` endpoint
does) if
it wants to back the HTTP endpoint with a call-protocol operation. The
OAI-compatible proxy does this: the `/v1/chat/completions` handler
parses the OAI request, invokes the `openai/chat` (or `agent/chat`)
operation, and reformats the response as an OAI response. But this is
the custom route's choice — it could equally be a pure HTTP handler
that never touches the registry (a webhook receiver, a static asset
server, a legacy API shim with its own backend).
### 3. The default surface's reserved paths take precedence on collision
The default-surface paths are reserved: `/search`, `/schema`, `/call`,
`/batch`, `/subscribe`, `/healthz`, `/openapi.json`, and the MCP route.
(ADR-047 removed the direct-call `/{service}/{op}` surface, so it is no
longer a reserved path; a deployment that builds a per-operation
projection as a custom route is the one case where `/{service}/{op}`
patterns appear, and those custom routes are subject to the same
collision rule.) If a custom route collides with a reserved path, the
default surface wins — the custom route is silently shadowed (or the
construction panics/warns; the specific collision-handling is a
two-way-door implementation detail). A deployment that wants
`/v1/chat/completions` namespaces it away from the reserved set, which
is natural (`/v1/...` doesn't collide).
### 4. Custom routes carry the same auth middleware by default; per-route opt-out is the deployment's choice
Custom routes run under the same Bearer-auth resolution as the default
surface (the `Authorization: Bearer``resolve_from_token` path). A
deployment that wants a custom route to be unauthenticated (a public
webhook receiver, a health endpoint with a different shape than
`/healthz`) applies axum middleware to opt that route out of auth —
the deployment owns its custom routes' middleware stack. The
`HttpAdapter` provides the identity provider and the default auth
middleware; the custom `Router` the assembly layer passes in can
layer its own middleware on top. This is standard axum composition; no
alknet-specific mechanism.
### 5. Custom routes are not part of the published `to_openapi` doc
The `to_openapi` gateway doc (ADR-042, ADR-045) describes the 5
gateway endpoints — the default contract. Custom routes are
deployment-specific and not described by `to_openapi`. A deployment
that wants its custom routes documented for external consumers
generates its own OpenAPI doc for them (a separate projection, not
`to_openapi`). The default `info.version` semver (ADR-045) tracks the
gateway contract, not custom routes — custom routes have no
versioning contract with alknet; the deployment versions them however
it wants.
### 6. This does not change the default surface
A deployment that constructs `HttpAdapter` with no extra routes gets
exactly the behavior documented in `http-server.md` — direct-call,
gateway, `/healthz`, `/openapi.json`, MCP (feature-gated), decoy. The
extension point is purely additive. The default surface remains the
published contract (ADR-036, ADR-042, ADR-045); custom routes are a
deployment-specific addition on top, not a modification of it.
## Consequences
**Positive:**
- Deployments can wrap external API shapes (OAI-compatible,
Anthropic-compatible, legacy) around call-protocol operations without
forcing the external shape into the operation's input/output schema.
The "compatibility shim" pattern is first-class.
- The runner pattern (remote worker → `from_call` → hub → custom OAI
route → opencode) works end-to-end with no architectural gap. The hub
is a standard alknet node *plus* a deployment-specific HTTP surface.
- The extension point is standard axum composition — no alknet-specific
routing abstraction for deployers to learn. A developer who knows
axum can add routes.
- The default surface is unchanged for deployments that don't need
custom routes. No complexity tax for the common case.
**Negative:**
- The HTTP surface is no longer fully described by the alknet specs
alone — a deployment's custom routes are outside the architecture
docs. This is inherent to the extension point (the deployment owns
them); the specs describe the *default* surface and the *mechanism*,
not every possible custom route.
- A custom route that dispatches into the registry bypasses the
gateway's `AccessControl`-filtered `/search` discovery — the custom
route is responsible for its own authorization story. The default
Bearer-auth middleware covers the common case, but a custom route
that wants per-operation ACL checks must call
`OperationRegistry::invoke()` with a proper `OperationContext`
(caller identity from the resolved bearer token), not a bypass. The
`invoke()` path enforces `AccessControl` regardless of the entry
point (direct-call, gateway, or custom route), so this is not an
ACL bypass — but the custom route author must construct the context
correctly.
- Two deployments with custom routes have different HTTP surfaces —
there is no single "what does an alknet HTTP endpoint look like"
answer anymore. The default surface is the contract; custom routes
are deployment-specific variance. This is honest (deployments
*do* vary) but means the architecture docs describe the default, not
the union.
## Assumptions
1. **The assembly layer is the composition point.** Custom routes are
added at `HttpAdapter` construction, not registered dynamically at
runtime. This matches the static-registration constraint (OQ-04 /
ADR-010) for the `HandlerRegistry`; the `HttpAdapter`'s router is
likewise immutable after construction. Dynamic route addition would
require `ArcSwap<Router>` and is not part of this ADR.
2. **Custom routes are a deployment concern, not an alknet-crate
concern.** `alknet-http` provides the extension point (accepts the
extra `Router`); it does not provide custom route implementations.
The OAI-compatible proxy, the legacy API shim, the webhook receiver
are all written by the deployment (or a downstream crate like
`alknet-agent` that builds on `alknet-http`), not by `alknet-http`
itself.
3. **The default surface is the published contract; custom routes are
not.** ADR-036 (direct-call), ADR-042 (gateway), ADR-045 (versioning)
govern the default surface. Custom routes have no alknet-governed
compatibility contract — the deployment owns their stability. This
keeps the published-contract surface small and stable while allowing
arbitrary deployment-specific extension.
4. **axum's composition primitives are sufficient.** `Router::merge`,
`Router::nest`, and axum middleware cover the extension patterns
needed (custom routes, per-route auth opt-out, prefix namespacing).
No alknet-specific routing abstraction is required. If a future
need exceeds axum's composition (e.g., route-level dynamic dispatch),
that would be a separate ADR.
## References
- [ADR-010](010-alpn-router-and-endpoint.md) — static registration at
startup (the `HttpAdapter` router is immutable after construction,
same constraint)
- [ADR-042](042-openapi-gateway-pattern.md) — the gateway endpoints
(the default surface custom routes coexist with; reserved paths)
- [ADR-045](045-to-openapi-gateway-spec-versioning.md) — the published
doc versions the gateway contract, not custom routes
- [ADR-047](047-remove-direct-call-http-surface.md) — the direct-call
surface is removed; the gateway is the sole invoke path (a
deployment that wants the former per-operation HTTP surface builds it
as a custom route projection; this ADR §4 is the mechanism)
- `crates/http/http-server.md` — the `HttpAdapter` spec that gains the
`extra_routes` constructor parameter

View File

@@ -0,0 +1,281 @@
# ADR-047: Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path
## Status
Proposed
## Supersedes
The "direct path mapping" clause of [ADR-036](036-http-to-call-operation-mapping.md)
§Decision ("Direct path mapping is the default HTTP surface") and §HTTP
method semantics. ADR-036's other clauses (SSE projection, Bearer auth,
`/healthz`, stealth decoy, error mapping, `External`-only dispatch)
remain in force — they are independent of the routing decision and are
reaffirmed by this ADR (see §"What survives from ADR-036").
## Context
ADR-036 defined the HTTP surface as **direct path mapping**:
`POST /{service}/{op}``call.requested` for every `External`
operation. An operation `fs/readFile` was served at `POST /fs/readFile`,
one HTTP path per operation — a REST-like surface mirroring the call
protocol's `/{service}/{op}` operation paths. This was the original HTTP
contract, decided before the simplified-contract / gateway-pattern
work landed.
Since then, three shifts made the direct-call surface a contradiction
with the architecture's settled model:
1. **ADR-042** replaced `to_openapi`'s per-operation-paths projection
with the **gateway pattern** — 5 fixed endpoints (`/search`,
`/schema`, `/call`, `/batch`, `/subscribe`) where the per-caller
operation surface is discovered via `AccessControl`-filtered
`/search`, not preloaded into a static doc. The gateway's `/call`
endpoint is the invoke path: `POST /call` with
`{ operation: "/fs/readFile", input: {...} }`. This is the same
RPC-shape pattern MCP uses (`tools/call` with a tool name, ADR-041).
2. **The simplified contract is the few-fixed-endpoints model**, not a
per-operation REST tree. The whole point of the gateway pattern
(ADR-042) was to escape the "static full-surface dump" failure mode
(the Gitea anti-pattern: every operation gets a path, every caller
sees the full surface, per-caller access is an afterthought). The
direct-call surface is that anti-pattern at the HTTP level: every
`External` operation gets an HTTP path, the path exists regardless
of the caller's privilege, and the caller discovers what it can call
by trial-and-error `403`s. The gateway's `/search` exists precisely
to make the per-caller surface the default; the direct-call surface
re-introduces the problem the gateway solved.
3. **ADR-046** added the custom-routes extension point, so a
deployment that genuinely wants a REST-like per-operation HTTP
surface (e.g., to match a legacy API shape) builds it as a custom
route projection (additive, deployment-owned, not the alknet
default contract). The direct-call surface is no longer the only
way to get per-operation HTTP paths; it's the *default* way, and
it's the wrong default.
The result: the HTTP router currently has **two ways to invoke an
operation** — the direct-call surface (`POST /fs/readFile`) and the
gateway (`POST /call` with the operation name in the body). That is the
contradiction: the simplified contract says "a few core endpoints,"
and the direct-call surface is a second, per-operation invoke path that
duplicates the gateway's `/call` with a scheme the gateway was built
to replace. ADR-042's amendment explicitly preserved the direct-call
surface ("unchanged"); that preservation was a leftover from before
the simplified contract was fully thought through, not a deliberate
endorsement of two invoke paths.
### The clean-up
The direct-call surface is residual from early-stage planning, the
same way the pre-ADR-042 `to_openapi` per-operation-paths projection
was residual. ADR-042 cleaned up `to_openapi`; this ADR cleans up the
HTTP handler's routing. The gateway becomes the sole invoke path; the
per-operation HTTP paths go away.
### What about HTTP clients that knew operation names?
A client that previously called `POST /fs/readFile` now calls
`POST /call` with `{ "operation": "/fs/readFile", "input": {...} }`. The
operation name is still the call protocol's `/{service}/{op}` form
(OQ-13, unchanged) — it moves from the HTTP path to the request body.
The gateway's `/call` is the standard invoke endpoint; the direct path
was a REST-like affordance that the simplified contract deliberately
drops. This is a breaking change for any HTTP client built against the
direct-call surface, which is exactly why it needs an ADR — but the
direct-call surface has not been implemented or published yet (the
alknet-http crate is specced, not shipped), so the "break" is
paper-only: no external client depends on it.
## Decision
### 1. The gateway is the sole invoke path; the direct-call surface is removed
The `HttpAdapter`'s router serves the **5 fixed gateway endpoints**
(`/search`, `/schema`, `/call`, `/batch`, `/subscribe` — ADR-042) as
the only way to invoke operations over HTTP. There is no
`POST /{service}/{op}` direct-call surface. An HTTP client invokes an
operation by `POST /call` with
`{ "operation": "/{service}/{op}", "input": {...} }`.
The router's operation-invoke surface is the gateway's `/call`
endpoint, not a per-operation path set. The operation name is in the
request body, not the HTTP path — same shape as MCP's `tools/call`
(ADR-041) and the call protocol's own `call.requested`
(`operationId` + `input`).
### 2. The HTTP method semantics move to the gateway endpoints
ADR-036's `OperationType` → HTTP method mapping (`Query``GET`,
`Mutation``POST`, `Subscription``SSE`) no longer applies per-operation
at the HTTP path level, because there are no per-operation HTTP paths.
The gateway endpoints have fixed methods (ADR-042's table):
`/search` `GET`, `/schema` `GET`, `/call` `POST`, `/batch` `POST`,
`/subscribe` `GET` (SSE). The `OperationType` of the *called operation*
is carried in the request/result, not expressed in the HTTP verb — the
client calls `/call` with the operation name; the operation's type is
the registry's concern, not the HTTP method's. A `Query` operation and a
`Mutation` operation both go through `POST /call`; the distinction is
in the operation spec (discovered via `/schema`), not the HTTP surface.
### 3. What survives from ADR-036
ADR-036's routing decision is superseded, but its other clauses are
independent of routing and remain in force:
- **SSE projection for subscriptions over `h2`/`http/1.1`** (§Streaming
projection). The gateway's `/subscribe` endpoint uses this SSE
projection (ADR-042 §2). The framing (`call.responded` → SSE `data:`
frame, `call.completed` → stream close, `call.aborted` → error frame)
is unchanged; it is now the `/subscribe` endpoint's behavior, not a
per-operation SSE stream.
- **Bearer auth** (§Auth). `Authorization: Bearer`
`resolve_from_token` on every gateway endpoint. Unchanged.
- **`/healthz`** (§`/healthz` and operational endpoints). Raw route, no
auth, no call protocol. Unchanged.
- **Stealth decoy** (§Stealth mode). Unknown paths get the decoy.
Unchanged — and now *all* operation invocations go through the 5
gateway paths, so the "unknown path" surface is larger (anything not
`/search`, `/schema`, `/call`, `/batch`, `/subscribe`, `/healthz`,
`/openapi.json`, the MCP route, or a custom route per ADR-046 is
decoy).
- **Error mapping** (the call `code` → HTTP status table in
http-server.md, ADR-023). The gateway's `/call` endpoint returns the
same error mapping. Unchanged in mechanism; the entry point is
`/call` instead of `/{service}/{op}`.
- **`External`-only dispatch** (Assumption 2). The gateway's `/call`
returns `404` (`NOT_FOUND`) for `Internal` operations, same as the
direct-call surface did. The `AccessControl` check runs on the called
operation regardless of the entry point.
- **Abort cascade on HTTP disconnect** (Consequences, citing ADR-016).
An HTTP client disconnecting mid-`/subscribe` is detected as a stream
close and sends `call.aborted`, cascading to descendants. Unchanged.
### 4. A deployment that wants per-operation HTTP paths builds them as custom routes (ADR-046)
A deployment that genuinely needs a REST-like per-operation HTTP
surface (to match a legacy API shape, to serve clients that can't
adapt to the gateway) builds it as a **custom route projection**
(ADR-046): the assembly layer injects an `axum::Router` with
`POST /{service}/{op}` handlers that dispatch into
`OperationRegistry::invoke()`. This is deployment-owned, additive, and
explicitly *not* the alknet default contract — the same status as an
OAI-compatible proxy. The direct-call surface is no longer a built-in
default; it's a projection a deployment can build if it needs it, on
the same extension point as any other custom HTTP surface.
This keeps the default surface small (5 gateway endpoints) while
preserving the *capability* for REST-like access — it just isn't free
by default, which is correct, because the per-operation path surface
has real costs (the static-surface problem) that the gateway avoids.
### 5. `to_openapi` describes the gateway, unchanged
`to_openapi` (ADR-042, ADR-045) already describes the 5 gateway
endpoints, not per-operation paths. Removing the direct-call surface
does not change what `to_openapi` generates — it already generated the
gateway doc. The `info.version` semver (ADR-045) tracks the gateway
contract; the direct-call surface was never in that contract. No change
to `to_openapi` or its versioning.
## Consequences
**Positive:**
- One invoke path over HTTP, not two. The HTTP surface is the 5 gateway
endpoints — exactly the "few core endpoints" of the simplified
contract. The contradiction with the gateway pattern is resolved.
- The per-caller API surface is the default, structurally. An HTTP
client cannot stub its toe on `POST /admin/deleteUser` because that
path does not exist; it calls `/call` with the operation name, and
`/search` tells it what it can call. The Gitea failure mode is
structurally impossible at the HTTP level, not just at the discovery
level.
- The HTTP surface is honest about what the call protocol is: an RPC,
not a REST API. The gateway's `/call` with `{ operation, input }` is
the call protocol's own shape; the direct path mapping was a REST
disguise that didn't fit (the flat JSON input, no path/query/body
split — ADR-042 §"The flat→structured problem").
- A deployment that wants REST-like per-operation paths still can, via
custom routes (ADR-046) — it's an explicit choice with its own costs,
not a default that leaks the static-surface problem into every
deployment.
- No change to `to_openapi` (already described the gateway), to the
SSE projection (now on `/subscribe`), to Bearer auth, to `/healthz`,
to stealth, or to error mapping. The cleanup is narrow: the routing
decision only.
**Negative:**
- An HTTP client that knew an operation name can no longer call it at
a predictable HTTP path. It must call `/call` with the operation name
in the body. This is one layer of indirection, but it's the same
indirection MCP uses and the same shape the call protocol uses
natively. The operation name (OQ-13's `/{service}/{op}` form) is
unchanged — it moves from the path to the body.
- The HTTP surface is RPC-shaped, not REST-shaped. A developer
expecting `POST /fs/readFile` sees `POST /call` with a body instead.
This is honest (the call protocol is a flat JSON RPC, ADR-042 §3), but
it's a departure from the REST conventions ADR-036's direct-call
surface offered. A deployment that needs the REST shape builds it as a
custom route projection (ADR-046).
- The `OperationType` → HTTP method mapping (`Query``GET` etc.) no
longer applies at the HTTP level. A `Query` operation and a
`Mutation` operation both go through `POST /call`. The distinction is
in the operation spec (visible via `/schema`), not the HTTP verb. This
loses a small amount of HTTP-level signal (a load balancer can't tell
a read from a write by method), but the call protocol's
`OperationType` was always a registry concern, not an HTTP concern —
the direct-call surface borrowed HTTP verbs to express it, and the
gateway doesn't.
## Assumptions
1. **No external client depends on the direct-call surface.** The
alknet-http crate is specced, not shipped; the direct-call surface
has not been published. Removing it is a paper-only break — no
deployed client breaks. This is why the cleanup is cheap now and
would be expensive after implementation.
2. **The gateway's `/call` is a sufficient invoke path for HTTP
clients.** Any operation callable via `POST /{service}/{op}` is
callable via `POST /call` with the operation name in the body. The
operation name form (`/{service}/{op}`, OQ-13) is unchanged. The
input/output shapes are unchanged. The only difference is where the
operation name lives (path vs body).
3. **A deployment needing REST-like per-operation paths builds them
explicitly.** Via ADR-046 custom routes. This is not a common need —
the gateway's `/call` covers the standard invoke case, and the
OAI-compatible-proxy pattern (ADR-046) covers the "match an external
API shape" case. The direct-call surface was a default that served
neither case particularly well (it wasn't REST-conventional, per
ADR-036 §Negative, and it leaked the static-surface problem).
4. **The gateway endpoints are stable (ADR-042 Assumption 1).**
Removing the direct-call surface does not change the gateway
endpoint set; the 5 endpoints are the published contract. This ADR
narrows the HTTP surface *to* that contract, it does not modify the
contract itself.
## References
- [ADR-036](036-http-to-call-operation-mapping.md) — the ADR whose
routing decision this supersedes (§Decision, §HTTP method semantics);
its other clauses survive (§"What survives from ADR-036")
- [ADR-042](042-openapi-gateway-pattern.md) — the gateway pattern that
made the direct-call surface redundant; its amendment to ADR-036
preserved the direct-call surface, which this ADR reverses
- [ADR-044](044-defer-webtransport-browsers-use-websocket.md) —
WebSocket is the browser bidirectional path (the direct-call surface
was the `h2`/`http/1.1` one-directional path; removing it does not
affect WebSocket, which carries the call protocol natively)
- [ADR-046](046-assembly-layer-custom-http-routes.md) — the extension
point a deployment uses to build a per-operation HTTP surface if it
needs one (the direct-call surface's replacement for the rare case)
- [ADR-045](045-to-openapi-gateway-spec-versioning.md) — `to_openapi`
versions the gateway contract (unchanged; the direct-call surface
was never in the contract)
- OQ-13 (resolved) — operation path format `/{service}/{op}` is
unchanged; it moves from the HTTP path to the `/call` request body
- `crates/http/http-server.md` — the spec whose router surface this ADR
narrows to the gateway endpoints

View File

@@ -805,31 +805,56 @@ is a feature extension, not an unmade architecture decision.
- **Origin**: [ADR-017](decisions/017-call-protocol-client-and-adapter-contract.md)
Consequences, [http-adapters.md](crates/http/http-adapters.md)
- **Status**: open
- **Status**: **resolved** (2026-06-30 by ADR-045)
- **Door type**: One-way (after first publication), two-way (before)
- **Priority**: medium
- **Resolution**: ADR-017 Consequences notes that published `to_*`
specs are compatibility contracts: once a generated OpenAPI spec is
published and external clients build against it, the mapping
semantics (e.g., subscriptions → SSE long-poll, error codes → HTTP
statuses) become a de facto contract. Changing the mapping later
breaks every client. `to_openapi` mapping choices are two-way *before*
first publication but one-way *after*.
- **Priority**: medium → resolved
- **Resolution**: **[ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md)
commits the versioning scheme.** The gateway pattern (ADR-042)
dissolved most of the original concern: the published doc describes
**5 fixed gateway endpoints** (`/search`, `/schema`, `/call`,
`/batch`, `/subscribe`), not the per-operation surface. Per-caller
operation changes (add/remove/modify an operation, change an
operation's schema) do **not** change the published doc — the
operation set is discovered at runtime via `AccessControl`-filtered
`/search`, not preloaded into the doc. So the version does not churn
on every operation change (the original OQ-39 worry, framed under the
pre-ADR-042 per-operation-paths model).
The versioning strategy for generated OpenAPI specs needs
specifying: version the generated spec (e.g., an OpenAPI `info.version`
tied to the registry's `External` operation set version) and emit a
spec version marker so consumers can detect mapping changes. The
exact versioning scheme (semver tied to operation additions/changes,
a content-hash, a monotonically-increasing counter) is a two-way-door
implementation detail before first publication; the one-way constraint
is that the version marker is emitted and consumers can detect
breaking changes. This is the "published artifact is a contract"
blind spot in ADR-009's framework (it classifies doors by reversal
cost in the codebase, not by compatibility cost for external
consumers).
- **Cross-references**: ADR-009, ADR-017, ADR-023, ADR-036,
[http-adapters.md](crates/http/http-adapters.md)
What remains is narrow: how the published gateway doc signals its
version. The decision:
1. **`to_openapi` emits `info.version` as semver.** Standard OpenAPI
field, standard semver interpretation — no alknet-specific
detection mechanism.
2. **The version tracks the gateway endpoint contract, not the
operation set.** Major = breaking change to the gateway (endpoint
removed/renamed, required request field added, response shape
changed, error-mapping semantics changed per ADR-023); Minor =
additive (new endpoint, new optional field); Patch = wording/docs.
Per-caller operation changes do **not** bump the version.
3. **Bump on change to the gateway shape, not on regeneration.**
A restart that regenerates the same gateway shape yields the same
version.
4. **Consumers detect breaking changes via the major version.** A
client compares `info.version`'s major component to the version it
built against; a major bump signals "re-read the doc, something
broke." Minor/patch are informational.
5. **The additive traditional per-operation-paths projection
(ADR-042 §5) versions independently** on its own schedule — its
surface *does* change with the operation set, so its versioning is
the per-operation churn the original OQ-39 framed. That projection
is opt-in and out of scope for ADR-045; the gateway doc is the
default published contract and the one ADR-045 governs.
The original "version marker emitted so consumers can detect mapping
changes" constraint (from ADR-017 Consequences) is satisfied by
`info.version` semver. ADR-045 lifts the "published artifact is a
contract" blind spot in ADR-009's framework (it classifies doors by
reversal cost in the codebase, not by compatibility cost for external
consumers) into its Context and honors the constraint without changing
ADR-009's framework.
- **Cross-references**: ADR-009, ADR-017, ADR-023, ADR-036, ADR-042,
ADR-045, [http-adapters.md](crates/http/http-adapters.md)
### OQ-40: reqwest Client Config and Connection Pooling