Files
alkhttp/docs/architecture/http-adapters.md
T
glm-5.3-flash 320ea87b08 docs: port architecture specs and ADRs from alknet-http; write new alkhttp ADRs 067-070
Phase 1 (SDD) — architecture documentation:

Ported specs (adapted for alkcall, producer/consumer terms, 6-endpoint
gateway, channels-over-WS, Sub/Pub operation types):
- overview.md, http-server.md, http-adapters.md, http-mcp.md
- README.md index (rewritten for alkhttp)

New ADRs:
- 067: WebSocket carries the channels protocol (8-byte chunk demux,
  channel 0 = alk/call, upgrade path /alk/channels)
- 068: gateway /publish endpoint for Pub operations (NDJSON body)
- 069: WebTransport out of scope in alkhttp (alknet concern)
- 070: from_wss consumer adapter (wss feature, tokio-tungstenite)

Ported ADRs (25, same numbers, port notes + amendments where the
extraction changed facts): 001-004, 010, 014, 015, 017, 022, 023, 027,
034, 036, 037, 039, 041, 042, 044, 045, 046, 047, 048, 049, 051, 066.

websocket.md rewritten for the channels session; open-questions.md
seeded (OQ-01 WS byte-stream adapter, OQ-02 /publish framing,
OQ-03 from_wss reconnect, OQ-04 browser client ownership).

Verified: cargo test, clippy -D warnings, fmt, doc --no-deps.
2026-08-27 14:19:24 +00:00

38 KiB

status, last_updated
status last_updated
draft 2026-08-27

HTTP Adapters — from_openapi, from_jsonschema, and to_openapi

The OpenAPI-direction adapters plus the single-endpoint adapter: from_openapi imports external HTTP APIs described by a full OpenAPI document, from_jsonschema imports a single non-standard / non-OpenAPI HTTP endpoint described by a caller-supplied OperationSpec, and to_openapi generates an OpenAPI spec from the local registry's External operations. This document covers all three, the error fidelity (alkcall ADR-016 — Operation Error Schemas), and the no-env-vars credential injection point.

What

Three adapters, all in alkhttp:

  1. from_openapi — parses an OpenAPI document, constructs a HandlerRegistration bundle per OpenAPI operation with a forwarding handler that calls the external HTTP endpoint via reqwest, and returns the bundles for registration in the OperationRegistry. The adapter implements OperationAdapter (the async trait from alkcall::client — alkcall ADR-022 §5, Call Protocol Client and Adapter Contract). Provenance is FromOpenAPI (leaf, composition_authority: None, scoped_env: None, Internal by default — alkcall ADR-017/018).
  2. from_jsonschema — registers a single HTTP endpoint as a call-protocol operation, one at a time, for non-standard / non-OpenAPI / basic REST endpoints that don't have a full OpenAPI document. The caller supplies an OperationSpec + HttpServiceConfig
    • path template + HTTP method; the adapter builds one HandlerRegistration with a reqwest forwarding handler (the same handler shape as from_openapi) and FromJsonSchema provenance. Implements OperationAdapter. See ADR-066.
  3. to_openapi — generates an OpenAPI document from the local registry's External operations. A pure projection: it consumes the registry, it does not produce entries for it (alkcall ADR-022 §5 — the to_* adapters are outbound projections, not OperationAdapter implementations). Served at GET /openapi.json by the HTTP server.

from_openapi

pub struct FromOpenAPI {
    spec: OpenAPISpec,
    config: HttpServiceConfig,
}

#[async_trait]
impl OperationAdapter for FromOpenAPI {
    async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}

Type definitions

/// A parsed OpenAPI document. The internal representation is
/// `serde_json::Value`-based (ADR-051 §Assumptions #1) — both JSON and
/// YAML parse paths produce the same `serde_json::Value` tree, then feed
/// the existing `from_value` constructor. A future swap to
/// `openapiv3::OpenApi` is a two-way door: both JSON and YAML constructors
/// adapt in lockstep, since the constructor is the adapter between wire
/// format and internal type. The one-way constraint is that
/// `from_openapi` accepts a standard OpenAPI 3.x JSON/YAML doc and
/// `to_openapi` produces one. Both directions share the same Rust type,
/// but not the same document shape: `from_openapi` consumes traditional
/// per-operation-paths docs (one path per operation), while `to_openapi`
/// produces the 6-endpoint gateway doc (ADR-042, extended with `/publish`
/// by ADR-068). The type is shared; the shape is not.
///
/// Input formats (ADR-051): `from_openapi` accepts both JSON and YAML.
/// JSON is parsed via `serde_json`; YAML via `yaml_serde` (the maintained
/// fork of the deprecated `serde_yaml`). Both paths produce the same
/// `serde_json::Value`-based internal type — there is one
/// `OpenAPISpec`, not a JSON and a YAML variant. `from_str` detects
/// format by trying JSON first and falling back to YAML (defensive
/// default — ADR-051 §2: JSON's stricter grammar is immune to any
/// YAML-specific type interpretation, present or future; with
/// `yaml_serde` 0.10.x's YAML 1.2 core schema the coercion the original
/// rationale cited is not present, but JSON-first locks the contract
/// against a future YAML-parser swap).
pub struct OpenAPISpec {
    pub info: OpenAPIInfo,
    pub paths: BTreeMap<String, PathItem>,
    pub components: Option<Components>,
    // ... OpenAPI 3.x fields as needed
}

impl OpenAPISpec {
    pub fn from_json(doc: &str) -> Result<Self, AdapterError>;   // JSON input
    pub fn from_yaml(doc: &str) -> Result<Self, AdapterError>;   // YAML input
    pub fn from_str(doc: &str) -> Result<Self, AdapterError>;    // format-detecting (JSON-first, YAML-fallback — ADR-051 §2)
    pub fn from_value(raw: Value) -> Result<Self, AdapterError>; // pre-parsed serde_json::Value
}

/// Configuration for an HTTP-backed adapter (`from_openapi`). Carries
/// the base URL, auth credentials (from `Capabilities` at registration,
/// not env vars — the no-env-vars invariant), and optional headers. The
/// `auth` field is the auth scheme the external API expects (bearer,
/// apiKey, basic); the credential itself is read from
/// `OperationContext.capabilities` at call time, not stored here.
pub struct HttpServiceConfig {
    pub namespace: String,
    pub base_url: String,
    pub auth: Option<HttpAuthScheme>,
    pub default_headers: HashMap<String, String>,
}

pub enum HttpAuthScheme {
    Bearer,                          // Authorization: Bearer <token>
    ApiKey { header_name: String },  // e.g., X-API-Key: <key>
    Basic,                           // Authorization: Basic <credentials>
}

The adapter:

  1. Parses the OpenAPI document (OpenAPISpecpaths, components, $ref resolution). Accepts JSON or YAML (ADR-051 — JSON via serde_json, YAML via yaml_serde; from_str detects format JSON-first/YAML-fallback, a defensive default — ADR-051 §2). On parse failure, returns AdapterError::SchemaParse. The TS prior art (@alkdev/operations/src/from_openapi.ts) shows the parsing patterns: resolveRef for $ref, resolveRefsRecursive for nested refs, buildInputSchema (parameters + request body → input JSON Schema), buildOutputSchema (200/201 response → output JSON Schema), detectOperationType (SSE response → Sub, GET → Query, else Mutation). Pub ops are not produced by from_openapi — OpenAPI has no representation for producer→consumer streaming in v1.
  2. For each (path, method, operation) in spec.paths, constructs a HandlerRegistration:
    • spec.name = the operationId (or a generated ${method}_${path_parts} name if operationId is absent — same normalization as the TS normalizeOperationId).
    • spec.namespace = the config.namespace (the importing deployment's name for the service, not the OpenAPI doc's info.title).
    • spec.op_type = Query / Mutation / Sub (detected as Sub from the method + response content type, same as TS).
    • spec.visibility = Internal (adapter-registered ops are composition material, not directly callable from the wire — alkcall ADR-017).
    • spec.input_schema / output_schema = the JSON Schemas built from the OpenAPI parameters/responses.
    • spec.error_schemas = the ErrorDefinitions built from the non-2xx OpenAPI responses (alkcall ADR-016 §5 — see Error Fidelity below).
    • spec.access_control = AccessControl::default() (the adapter doesn't declare scopes; the composing handler that reaches the imported op gates access).
    • handler = a forwarding handler (see Forwarding Handler below).
    • provenance = FromOpenAPI, composition_authority: None, scoped_env: None (leaf — alkcall ADR-018).
    • capabilities = the credentials the forwarding handler needs (the bearer token / API key for the external HTTP endpoint, injected by the assembly layer at registration — see No-Env-Vars below).
  3. Returns the bundles. The caller (the assembly layer) registers them in the OperationRegistry.

Forwarding handler

The forwarding handler is stored in the HandlerRegistration as a HandlerKind (alkcall ADR-021). At call time, it:

  1. Reads the call input (serde_json::Value).
  2. Builds the outbound HTTP request:
    • URL path: substitutes path parameters ({id} → input value), appends query parameters from input fields not in the path.
    • Method: the OpenAPI operation's method.
    • Headers: Content-Type: application/json + the auth header built from context.capabilities (see No-Env-Vars below).
    • Body: the body field of the input (for Mutation/Sub).
  3. Sends the request via the shared HTTP client (see HTTP Client below).
  4. For a Query/Mutation: parses the response body (JSON, text, or binary — same content-type branching as the TS createHTTPOperation), wraps it in a ResponseEnvelope, returns. Registered as HandlerKind::Once — a Handler returning a single ResponseEnvelope.
  5. For a Sub (text/event-stream response): streams call.responded events as the SSE chunks arrive (same SSE parsing as the TS parseSSEFrames), then the stream ends on SSE close (which becomes call.completed on the wire). Registered as HandlerKind::Stream — a StreamingHandler returning a BoxStream<ResponseEnvelope> (alkcall ADR-021). Each SSE data: frame becomes a ResponseEnvelope::ok(); an HTTP error (non-2xx) becomes a single ResponseEnvelope::error() and ends the stream.
  6. On HTTP error (non-2xx): maps to the declared ErrorDefinition by HTTP status code (see Error Fidelity below), returns a CallError.

The handler is opaque to alkcall's CallAdapter — it's a HandlerKind the registry dispatches (via invoke() for Once, invoke_streaming() for Stream). alkcall never sees reqwest.

HTTP client (reqwest)

alkhttp maintains a shared HTTP client, constructed once and reused across all from_openapi/from_mcp forwarding handlers. The client owns connection pooling, keep-alive, TLS, and a retry stack. The shared type is reqwest_middleware::ClientWithMiddleware, not a bare reqwest::Client — both retry and Retry-After are middleware on the stack, and middleware requires the ClientWithMiddleware wrapper.

The middleware stack has two layers:

  1. RetryTransientMiddleware (from reqwest-retry) — exponential backoff on transient failures (connection errors, 5xx). The "retry N times with increasing intervals" part. Configured via an ExponentialBackoff policy at client construction.
  2. Inlined RetryAfterMiddleware — parses the Retry-After header on 429/503 and sleeps before the next request to that URL. The "respect what the server told you" part. Inlined (MIT, ~50 lines of real logic) from melotic/reqwest-retry-after, not pulled as a dependency: the crate is complementary to reqwest-retry (whose default strategy does not honor Retry-After), and inlining lets the upstream's unbounded HashMap<Url, SystemTime> storage be bounded for a long-running process.

Pooling, keep-alive, and TLS come from reqwest::ClientBuilder defaults; outbound TLS uses the system trust store (standard HTTPS to external APIs like OpenAI, Anthropic). Custom CA bundle + client certs are an optional config for self-hosted API gateways (two-way-door implementation detail; the credential comes from Capabilities, the TLS trust comes from the system).

Credential injection happens per-request (from OperationContext.capabilities), not at client construction — the client is shared across all operations, the credentials are per-call.

Hot-reload of the pooling/retry config is rebuild-and-swap: a config change rebuilds the ClientWithMiddleware and swaps it via ArcSwap (the same pattern ConfigIdentityProvider uses for its ArcSwap<DynamicConfig> reload — see the alkcall crate's docs/architecture/decisions/006-authcontext-structure.md and docs/architecture/decisions/025-peerentry-and-identity-id-decoupling.md). A rebuild drops the connection pool / keep-alive state, which is acceptable — a config change wanting a fresh pool is the case that triggers it. The retry policy is baked into the middleware at ClientBuilder::build() time; live policy mutation is not supported by reqwest-retry, so cheap per-policy updates are not part of the model.

The exact pooling/retry config (pool size, retry count, timeout defaults, hot-reloadability via DynamicConfig) is a two-way-door implementation detail (OQ-40, now resolved); the one-way constraint is that alkhttp owns its HTTP client (no env-var-based client config, no shared global client).

Downstream layering boundary. The agent crate's provider SSE normalization (replicating the solid part of aisdk's pattern — the Vercel-UI-message normalization that maps different providers' SSE to a common shape) sits on top of this ClientWithMiddleware: it consumes the reqwest::Response stream the forwarding handler produces and emits call.responded events. It does not replace the client or own transport/pooling/retry. alkhttp owns transport; the agent crate owns provider-specific SSE → Vercel-UI-message mapping. The aisdk core/client.rs reference for HTTP client construction is not carried forward — its env-var config and hand-rolled retry are the anti-patterns discarded in favor of the middleware stack above. The @alkdev/operations/src/from_openapi.ts SSE normalization pattern is separate and stays referenced in the Forwarding Handler section above (the parseSSEFrames, createHTTPOperation, content-type branching patterns).

No-Env-Vars credential injection

The forwarding handler is the credential injection point for the no-env-vars architecture. The handler reads context.capabilities.get("<service>") (e.g., "openai", "vastai", "github"), extracts the credential, and injects it into the outbound HTTP request:

  • Bearer token → Authorization: Bearer <token>.
  • API key → the header the OpenAPI spec declares (e.g., X-API-Key: <key>, or Authorization: ApiKey <key> — the HTTPServiceConfig.auth in the TS prior art shows the three auth types: bearer, apiKey, basic).
  • Basic auth → Authorization: Basic <credentials>.

The credential comes from Capabilities, which was populated by the dispatch path from the HandlerRegistration.capabilities bundle (alkcall ADR-018 §6), which was populated by the assembly layer from the vault (ADR-014). The handler never reads std::env::var. This is the spec-level invariant: no handler reads outbound credentials from any source other than OperationContext.capabilities. See overview.md and the alkcall crate's docs/architecture/client-and-adapters.md.

from_jsonschema

from_jsonschema registers a single HTTP endpoint as a call-protocol operation, one at a time. It is functionally similar to from_openapi but for one endpoint instead of a full OpenAPI document — for non-standard, non-OpenAPI, or basic REST endpoints that don't have a paths object, an operationId, or components. The caller supplies the schema directly; the adapter builds a reqwest forwarding handler identical in shape to from_openapi's. See ADR-066.

pub struct FromJsonSchema {
    spec: OperationSpec,
    config: HttpServiceConfig,
    path_template: String,
    method: String,
    http_client: Arc<SharedHttpClient>,
}

#[async_trait]
impl OperationAdapter for FromJsonSchema {
    async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}

The adapter:

  1. Takes an OperationSpec (name, op type, input/output JSON Schema, error_schemas, access_control, visibility), an HttpServiceConfig (base URL, auth scheme, default headers — the same config type from_openapi uses), a path template (e.g. /users/{id}/posts), and an HTTP method (e.g. GET).
  2. Builds one HandlerRegistration:
    • spec = the caller-supplied OperationSpec (the caller already has the JSON Schemas; no parsing needed).
    • handler = a reqwest forwarding handler, identical in shape to from_openapi's: builds the HTTP request (path-template substitution, query params, body), injects credentials from context.capabilities, sends via the shared HTTP client, parses the response (JSON / text / binary — same content-type branching). For Sub op type, registers a StreamingHandler (alkcall ADR-021) expecting text/event-stream.
    • provenance = FromJsonSchema (leaf, composition_authority: None, scoped_env: None — alkcall ADR-018).
    • capabilities = the credentials the forwarding handler needs (same no-env-vars path as from_openapi).
  3. Returns the single bundle. The caller registers it in the OperationRegistry.

Relationship to from_openapi

from_jsonschema is functionally similar to from_openapi but for one endpoint instead of a full OpenAPI document. The two adapters share the forwarding-handler implementation, the credential injection path, the error-fidelity rule (HTTP_<status> prefix, alkcall ADR-016), the streaming shape (alkcall ADR-021), and the no-env-vars invariant (ADR-014). The difference is purely the input shape: a full document vs. a single endpoint. See ADR-066 §"Relationship to from_openapi" for the comparison table.

Origin (ADR-066)

from_jsonschema was originally placed in the call crate (alkcall ADR-022 §5) as a schema-only adapter with a NOT_FOUND-returning placeholder handler — broken, because an op in the registry needs a real handler. ADR-066 moved it to alkhttp and gave it a real reqwest forwarding handler. The FromJsonSchema provenance variant stays in alkcall (OperationProvenance, in alkcall::registry — alkcall ADR-027 records the move from the call-crate side); only the adapter implementation moved. See ADR-066 for the full rationale (why the placeholder was broken, why the "schema-only" concept conflated two things, why it was mispaced in the call crate).

to_openapi

pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec;

to_openapi generates an OpenAPI document with a fixed gateway endpoint set that gates access to the full operation registry — not one path per operation. This is the OpenAPI gateway pattern (ADR-042): the same principle as the MCP gateway (ADR-041) applied to OpenAPI. The external client (a code generator, a human developer, a fetch-based client) calls /search to discover operations, /schema to learn an operation's input shape, /call (or /batch, /subscribe, or /publish) to invoke. See ADR-042 for the rationale (the flat→structured split problem, the per-caller API surface problem).

The gateway endpoint set

to_openapi generates 6 fixed endpoints — the original five from ADR-042 plus /publish (ADR-068):

OpenAPI path Call protocol HTTP method Purpose
/search services/list GET List/search operations (AccessControl-filtered). Names + descriptions.
/schema services/schema GET Get an operation's full OperationSpec.
/call call.requested (Query/Mutation) POST Invoke an operation. Flat JSON body { operation, input }.
/batch multiple call.requested POST Invoke multiple operations. Array of { operation, input }.
/subscribe call.requested (Sub) POST (SSE) Invoke a streaming operation. Body { operation, input } (same shape as /call); response is text/event-stream.
/publish call.requested (Pub) POST (NDJSON) Publish to a Pub operation. Request body is newline-delimited JSON — each line is one published chunk, streamed to the operation's HandlerKind::Sink handler (alkcall ADR-046; ADR-068).

The input is always a flat JSON body — no path/query/body split to reverse-engineer. JSON Schema for the input/output is already in the OperationSpec; the gateway wraps it in OpenAPI's schema format without splitting parameters.

/subscribe and /publish are the two endpoints the MCP gateway excludes (ADR-041 — MCP tool calls are request/response; the tool-gateway surface has neither a streaming nor a client-publish shape). OpenAPI/SSE supports streaming; the gateway's /subscribe uses the same SSE projection ADR-036 describes — call.responded → SSE data: frames, call.completed → stream close. /publish inverts the direction: the HTTP caller's newline-delimited JSON lines become call.published chunks on the call protocol (ADR-068).

Per-caller API surface

The /search endpoint's results are AccessControl::check(identity)- filtered — the client sees only the operations it is authorized to call. The generated OpenAPI doc describes the 6 gateway endpoints (stable, same for every caller); the per-caller operation surface is discovered through /search, not preloaded into the doc. This is the key advantage over a traditional per-operation-paths OpenAPI doc: the per-caller API surface is the default (the Gitea failure mode — dumping admin ops to every caller — is structurally impossible). See ADR-042 §3.

Pure projection

to_openapi is a pure projection — it consumes the registry and produces a spec. It does not modify the registry; it does not register operations; it is not an OperationAdapter. The HTTP server serves the generated spec at GET /openapi.json (or a configured path).

Traditional per-operation-paths projection (additive)

A deployment that wants a traditional REST OpenAPI doc (per-operation paths with split parameters) can build it as a separate projection with HTTP-specific metadata (which fields are path params, etc.). The gateway pattern is the default to_openapi projection; the traditional projection is additive, not a replacement. See ADR-042 §5.

Shared dispatch spine with to_mcp

to_openapi's /call endpoint and to_mcp's call tool share the same dispatch spine (resolve identity → build OperationContextOperationRegistry::invoke() → map ResponseEnvelope). The wire-framing, discovery, streaming, and server-integration layers are per-gateway. See http-mcp.md §"Shared dispatch spine with to_openapi" and /workspace/@alkdev/alknet/docs/research/alknet-http-gateway-factoring/findings.md for the factoring recommendation (thin shared struct, not a trait).

Error Fidelity (alkcall ADR-016)

from_openapi maps OpenAPI non-2xx response status codes to ErrorDefinitions (alkcall ADR-016 §5). The normative rule (review #002 W20): from_openapi must not produce error codes that collide with the six protocol-level codes (NOT_FOUND, FORBIDDEN, INVALID_INPUT, INVALID_OPERATION_TYPE, INTERNAL, TIMEOUT). The adapter prefixes imported error codes with HTTP_ and the status number:

// OpenAPI: 404: { schema: NotFoundError }
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }

to_openapi projects error_schemas to the gateway endpoint's response definitions. The /call endpoint's responses include the operation-level errors (mapped by http_status), plus the protocol- level errors:

# /call endpoint responses
responses:
  '200': { schema: <output_schema for the called operation> }
  '400': { schema: <INVALID_INPUT error> }
  '401': { schema: <no bearer token> }
  '403': { schema: <FORBIDDEN — insufficient scopes> }
  '404': { schema: <NOT_FOUND — operation not registered or Internal> }
  '422': { schema: <operation-level error with http_status=422> }
  '429': { schema: <operation-level error with http_status=429> }
  '500': { schema: <INTERNAL> }
  '504': { schema: <TIMEOUT> }

The operation-level errors (with http_status) are surfaced on the /call endpoint's response — the gateway propagates the called operation's error_schemas as response definitions. This makes the adapter contract from alkcall ADR-022 faithful on the error axis — no silent dropping of error contracts. See alkcall ADR-016.

Why

from_openapi is how the alk stack composes external HTTP APIs (OpenAI, Anthropic, vast.ai, GitHub) into the call protocol. An operation imported via from_openapi is a first-class operation: it has a spec, it's discoverable via services/list, it can be composed by handlers, its errors are typed. The agent crate's LLM provider calls go through from_openapi-imported operations — that's how the no-env-vars invariant makes aisdk's env-var reads unreachable.

from_jsonschema fills the gap that from_openapi can't: endpoints that have no OpenAPI document. A non-standard REST endpoint, a basic internal API, or a third-party service with only a JSON Schema description can be registered as a call-protocol operation one at a time, with the same reqwest forwarding handler and the same no-env-vars credential path. The caller supplies the schema; the adapter supplies the handler. See ADR-066.

to_openapi is how external systems discover the alk stack's operation surface. A client generator, a human developer, or a fetch-based client reads the OpenAPI doc to learn the gateway's shape (6 fixed endpoints), then calls /search to discover what it can call (per-caller, AccessControl-filtered) and /schema to learn an operation's input shape. The gateway pattern avoids the flat→structured split that a traditional per-operation-paths projection would require, and makes the per-caller API surface the default (the Gitea failure mode — dumping admin ops to every caller — is structurally impossible). See ADR-042. The generated spec is a compatibility contract (alkcall ADR-022 Consequences) — once published, the 6-endpoint gateway shape is one-way.

Constraints

  • from_openapi/from_mcp handlers read credentials from OperationContext.capabilities, not std::env::var. This is the no-env-vars invariant (ADR-014). The handler implementations are verified against this invariant. from_jsonschema shares this invariant — same handler shape, same credential path (ADR-066).
  • from_openapi-registered ops are Internal by default. They are composition material, not directly callable from the wire (alkcall ADR-017). The handler that composes them is External. from_jsonschema ops are Internal by default for the same reason (ADR-066).
  • from_openapi error codes are prefixed HTTP_<status>. No collision with protocol-level codes (alkcall ADR-016, review #002 W20). from_jsonschema shares this rule (ADR-066).
  • from_openapi accepts JSON and YAML; from_str detects format JSON-first. JSON-first is a defensive default (ADR-051 §2 as amended): JSON's stricter grammar is immune to any YAML-specific type interpretation. With yaml_serde 0.10.x (YAML 1.2 core schema) the coercion the original rationale cited is not present, but JSON-first locks the contract against a future YAML-parser swap. from_str tries JSON first, falls back to YAML only if JSON parse fails. from_json/from_yaml are the explicit constructors for callers that know the format.
  • to_openapi is a pure projection. It consumes the registry, does not produce entries for it. Not an OperationAdapter.
  • to_openapi output is JSON. The published gateway doc is served at GET /openapi.json. YAML output is out of scope (ADR-051 §4); the gap this fills is on the consume side (importing external YAML schemas), not the publish side.
  • Published to_openapi specs are compatibility contracts. The 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 (alkcall ADR-022 Consequences, ADR-045, resolves OQ-39).
  • alkhttp owns its HTTP client. Shared across all forwarding handlers, constructed once. The shared type is reqwest_middleware::ClientWithMiddleware (middleware stack: RetryTransientMiddleware + inlined RetryAfterMiddleware). No env-var-based client config. Pooling/retry config is a two-way door, resolved in OQ-40.
  • TLS for outbound calls uses the system trust store by default. Standard HTTPS to external APIs (OpenAI, Anthropic). Custom CA bundle
    • client certs are an optional config for self-hosted API gateways. This is a two-way-door implementation detail; the credential (API key/token) comes from Capabilities, the TLS trust comes from the system.

Design Decisions

Decision ADR Summary
from_openapi is an OperationAdapter alkcall ADR-022 (Call Protocol Client and Adapter Contract) Async trait (alkcall::client); produces HandlerRegistration bundles. from_jsonschema clause superseded by ADR-066
from_jsonschema as HTTP-backed single-endpoint adapter in alkhttp ADR-066 Moved from_jsonschema from the call crate (broken schema-only placeholder — the call-crate side of the move is alkcall ADR-027) to alkhttp as a real reqwest-backed single-endpoint adapter; FromJsonSchema provenance stays in alkcall as a leaf
to_openapi is a projection, not an adapter alkcall ADR-022 (Call Protocol Client and Adapter Contract) Consumes the registry, doesn't produce entries
Adapter-registered ops are Internal alkcall ADR-017 (Privilege Model and Authority Context) from_openapi ops are composition material
from_openapi provenance is a leaf alkcall ADR-018 (Handler Registration, Provenance, and Composition Authority) composition_authority: None, scoped_env: None
Error fidelity (HTTP_<status> codes) alkcall ADR-016 (Operation Error Schemas) No collision with protocol codes; to_openapi projects back
No-env-vars credential injection ADR-014 Handler reads context.capabilities, not env vars
HTTP path = operation path (direct-call surface) ADR-036 → superseded by ADR-047 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 6 fixed gateway endpoints (search/schema/call/batch/subscribe/publish — /publish per ADR-068), 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 info.version semver tracks the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version
Streaming handler for subscriptions alkcall ADR-021 (Streaming Handler for Subscription Operations) from_openapi / from_jsonschema Sub ops register a StreamingHandler (HandlerKind::Stream); SSE response → BoxStream<ResponseEnvelope>; Query/Mutation stay HandlerKind::Once
YAML input + JSON-first format detection ADR-051 from_openapi accepts JSON and YAML (from_json/from_yaml/from_str); from_str is JSON-first/YAML-fallback (defensive default, §2 amended — yaml_serde 0.10.x is YAML 1.2, not 1.1; JSON-first locks the contract against a future parser swap); YAML dep is yaml_serde; to_openapi output stays JSON (out of scope, §4)

Open Questions

See open-questions.md for full details.

  • OQ-39 (resolved): to_openapi published-spec versioning — resolved by ADR-045: 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 credential injection. Two-way-door config shape, now resolved.

References

  • alkcall ADR-022 (Call Protocol Client and Adapter Contract) — the OperationAdapter trait (in alkcall::client), to_* are projections
  • ADR-066from_jsonschema as HTTP-backed single-endpoint adapter in alkhttp (supersedes alkcall ADR-022 §5's from_jsonschema clause; the call-crate-side record of the move is alkcall ADR-027)
  • alkcall ADR-016 (Operation Error Schemas) — error fidelity, HTTP_<status> prefix rule
  • overview.md — adapter location map, no-env-vars invariant
  • the alkcall crate's docs/architecture/client-and-adapters.mdOperationAdapter trait, AdapterError variants (OQ-26), no-env-vars invariant
  • /workspace/@alkdev/operations/src/from_openapi.ts — TypeScript prior art (parsing, SSE, auth headers, createHTTPOperation, parseSSEFrames — the SSE normalization patterns, not the client construction)
  • reqwest-retry crate (https://docs.rs/reqwest-retry/) — RetryTransientMiddleware / ExponentialBackoff retry policy
  • melotic/reqwest-retry-after (https://github.com/melotic/reqwest-retry-after) — RetryAfterMiddleware source (MIT, inlined, not a dependency)

Port notes

Corrections applied during the alknet → alkhttp port, beyond mechanical crate renames (alknet-httpalkhttp; alknet-call/alknet-corealkcall, with the adapter contract in alkcall::client, registry types in alkcall::registry, core in alkcall::core):

  1. Gateway is now 6 endpoints. /publish (POST, Pub operations; request body streamed as newline-delimited JSON, each line one published chunk) added per alkhttp ADR-068 (decisions/068-gateway-publish-endpoint.md — slug assumed; the ADR file does not exist yet). All "5 fixed endpoints" tables/phrasing updated to 6; the MCP-gateway exclusion now covers both /subscribe and /publish (the "one endpoint the MCP gateway excludes" claim from the source extended accordingly).
  2. OperationType::Subscription renamed Sub (alkcall rename); OperationType::Pub exists (producer→consumer streaming, HandlerKind::Sink, wire event call.published — alkcall ADR-046). The Query/Mutation/Sub detection mapping from OpenAPI is kept, with a one-line note that Pub ops are not produced by from_openapi (no OpenAPI representation in v1). The /publish gateway row is the only Pub surface in this doc.
  3. ADR cross-reference remapping. Call-protocol-internal decisions now live in the alkcall crate and are cited textually (no relative links across crates): old ADR-017 adapter contract → alkcall ADR-022; old ADR-022 handler registration → alkcall ADR-018 (§6 mapping preserved); old ADR-023 error schemas → alkcall ADR-016 (§5 mapping preserved); old ADR-015 privilege model → alkcall ADR-017; old ADR-049 streaming handler → alkcall ADR-021; Pub/HandlerKind::Sink → alkcall ADR-046; from_jsonschema provenance → alkcall ADR-027. alkhttp-owned ADRs keep their numbers and are linked relatively: 014, 036, 041, 042, 045, 047, 051, 066 (ADR-036's port to alkhttp is assumed — it is an HTTP-surface decision, consistent with the other gateway/server ADRs).
  4. Old ADR-035 reference (hot-reload pattern) had no verified mapping. The source cited alknet ADR-035 (Concrete Persistence Adapter Shapes) for the ConfigIdentityProvider ArcSwap rebuild-and-swap pattern; that ADR has no direct alkcall/alkhttp counterpart. Replaced with a textual pointer to the alkcall crate's ADR-006 (AuthContext Structure — documents the ArcSwap<DynamicConfig> reload) and ADR-025 (PeerEntry and Identity.id Decoupling — introduces ConfigIdentityProvider). Flagged as an inference, not a verified mapping.
  5. Producer/consumer terminology. Remaining "server"/"client" wording is deliberately retained where OpenAPI's inherent client/server directionality is meant (that directionality is a property of the OpenAPI protocol, not the call protocol) and for the HTTP server / external HTTP API server (transport roles, not call-protocol roles). No call-protocol server/client framing remains.
  6. Links. ../../decisions/decisions/; ../../open-questions.mdopen-questions.md (sibling, pending port); ../call/client-and-adapters.md → textual reference to the alkcall crate's docs/architecture/client-and-adapters.md; the mono-repo research findings path is kept as a textual absolute path. overview.md and http-mcp.md are sibling links to docs pending port.
  7. WebTransport/h3. The source document contained no h3/WebTransport references, so no "out of scope in alkhttp (ADR-069)" replacements were needed.
  8. Project naming. "how alknet composes/discover the alknet operation surface" → "the alk stack" in the Why section prose. Everything else (decision content, structure, section order, rationale) is preserved verbatim from the source.