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

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