From 2a6e4c371a3166e90bd2ee1e13131e911454baaf Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Tue, 30 Jun 2026 09:49:25 +0000 Subject: [PATCH] docs(http): resolve OQ-39; add ADRs 045-047; record pubsub prior art for WS path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- docs/architecture/README.md | 9 +- .../architecture/crates/call/call-protocol.md | 2 +- docs/architecture/crates/http/README.md | 36 ++- .../architecture/crates/http/http-adapters.md | 23 +- docs/architecture/crates/http/http-server.md | 204 +++++++++---- docs/architecture/crates/http/overview.md | 19 +- docs/architecture/crates/http/webtransport.md | 14 +- .../036-http-to-call-operation-mapping.md | 37 ++- .../decisions/042-openapi-gateway-pattern.md | 12 +- ...fer-webtransport-browsers-use-websocket.md | 78 ++++- .../045-to-openapi-gateway-spec-versioning.md | 177 +++++++++++ .../046-assembly-layer-custom-http-routes.md | 248 ++++++++++++++++ .../047-remove-direct-call-http-surface.md | 281 ++++++++++++++++++ docs/architecture/open-questions.md | 71 +++-- 14 files changed, 1082 insertions(+), 129 deletions(-) create mode 100644 docs/architecture/decisions/045-to-openapi-gateway-spec-versioning.md create mode 100644 docs/architecture/decisions/046-assembly-layer-custom-http-routes.md create mode 100644 docs/architecture/decisions/047-remove-direct-call-http-surface.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 49e5323..1db289b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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` 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 diff --git a/docs/architecture/crates/call/call-protocol.md b/docs/architecture/crates/call/call-protocol.md index 0cf2858..8b9a93f 100644 --- a/docs/architecture/crates/call/call-protocol.md +++ b/docs/architecture/crates/call/call-protocol.md @@ -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. diff --git a/docs/architecture/crates/http/README.md b/docs/architecture/crates/http/README.md index 8cd9c3a..27350fc 100644 --- a/docs/architecture/crates/http/README.md +++ b/docs/architecture/crates/http/README.md @@ -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_` 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` 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 diff --git a/docs/architecture/crates/http/http-adapters.md b/docs/architecture/crates/http/http-adapters.md index 079cb35..29a46d5 100644 --- a/docs/architecture/crates/http/http-adapters.md +++ b/docs/architecture/crates/http/http-adapters.md @@ -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_` 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 diff --git a/docs/architecture/crates/http/http-server.md b/docs/architecture/crates/http/http-server.md index b6163e3..c16e22e 100644 --- a/docs/architecture/crates/http/http-server.md +++ b/docs/architecture/crates/http/http-server.md @@ -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, } /// 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_` prefix for imported | +| Custom HTTP routes from the assembly layer | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | `extra_routes: Option` 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 diff --git a/docs/architecture/crates/http/overview.md b/docs/architecture/crates/http/overview.md index 2857c5a..593b7ea 100644 --- a/docs/architecture/crates/http/overview.md +++ b/docs/architecture/crates/http/overview.md @@ -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` 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" diff --git a/docs/architecture/crates/http/webtransport.md b/docs/architecture/crates/http/webtransport.md index d4f6b01..f40db10 100644 --- a/docs/architecture/crates/http/webtransport.md +++ b/docs/architecture/crates/http/webtransport.md @@ -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 diff --git a/docs/architecture/decisions/036-http-to-call-operation-mapping.md b/docs/architecture/decisions/036-http-to-call-operation-mapping.md index 23a827a..0a1397e 100644 --- a/docs/architecture/decisions/036-http-to-call-operation-mapping.md +++ b/docs/architecture/decisions/036-http-to-call-operation-mapping.md @@ -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 diff --git a/docs/architecture/decisions/042-openapi-gateway-pattern.md b/docs/architecture/decisions/042-openapi-gateway-pattern.md index 6be73cb..a5167e7 100644 --- a/docs/architecture/decisions/042-openapi-gateway-pattern.md +++ b/docs/architecture/decisions/042-openapi-gateway-pattern.md @@ -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 \ No newline at end of file diff --git a/docs/architecture/decisions/044-defer-webtransport-browsers-use-websocket.md b/docs/architecture/decisions/044-defer-webtransport-browsers-use-websocket.md index c4a6f1c..264a377 100644 --- a/docs/architecture/decisions/044-defer-webtransport-browsers-use-websocket.md +++ b/docs/architecture/decisions/044-defer-webtransport-browsers-use-websocket.md @@ -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; diff --git a/docs/architecture/decisions/045-to-openapi-gateway-spec-versioning.md b/docs/architecture/decisions/045-to-openapi-gateway-spec-versioning.md new file mode 100644 index 0000000..aaf1ac5 --- /dev/null +++ b/docs/architecture/decisions/045-to-openapi-gateway-spec-versioning.md @@ -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` \ No newline at end of file diff --git a/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md b/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md new file mode 100644 index 0000000..c207a6d --- /dev/null +++ b/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md @@ -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`, an +`Arc`, 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, + registry: Arc, + decoy: DecoyConfig, + /// Deployment-specific routes added by the assembly layer. None = + /// the default surface only. See ADR-046. + extra_routes: Option, +} +``` + +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` 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 \ No newline at end of file diff --git a/docs/architecture/decisions/047-remove-direct-call-http-surface.md b/docs/architecture/decisions/047-remove-direct-call-http-surface.md new file mode 100644 index 0000000..4c98567 --- /dev/null +++ b/docs/architecture/decisions/047-remove-direct-call-http-surface.md @@ -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 \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index b642d99..95fec86 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -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