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