- /call response map: 415 slot, identity-split 401 oneOf, PRJ-17 merge note, PRJ-19 shape-rejection note - /batch: no-500 statement (PRJ-21) and the BatchError in-band error component (PRJ-16b) - adapter.rs openapi.json test pin refreshed to 1.3.0
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:
from_openapi— parses an OpenAPI document, constructs aHandlerRegistrationbundle per OpenAPI operation with a forwarding handler that calls the external HTTP endpoint viareqwest, and returns the bundles for registration in theOperationRegistry. The adapter implementsOperationAdapter(the async trait fromalkcall::client— alkcall ADR-022 §5, Call Protocol Client and Adapter Contract). Provenance isFromOpenAPI(leaf,composition_authority: None,scoped_env: None,Internalby default — alkcall ADR-017/018).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 anOperationSpec+HttpServiceConfig- path template + HTTP method; the adapter builds one
HandlerRegistrationwith a reqwest forwarding handler (the same handler shape asfrom_openapi) andFromJsonSchemaprovenance. ImplementsOperationAdapter. See ADR-066.
- path template + HTTP method; the adapter builds one
to_openapi— generates an OpenAPI document from the local registry'sExternaloperations. A pure projection: it consumes the registry, it does not produce entries for it (alkcall ADR-022 §5 — theto_*adapters are outbound projections, notOperationAdapterimplementations). Served atGET /openapi.jsonby 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:
- Parses the OpenAPI document (
OpenAPISpec—paths,components,$refresolution). Accepts JSON or YAML (ADR-051 — JSON viaserde_json, YAML viayaml_serde;from_strdetects format JSON-first/YAML-fallback, a defensive default — ADR-051 §2). On parse failure, returnsAdapterError::SchemaParse. The TS prior art (@alkdev/operations/src/from_openapi.ts) shows the parsing patterns:resolveReffor$ref,resolveRefsRecursivefor nested refs,buildInputSchema(parameters + request body → input JSON Schema),buildOutputSchema(200/201 response → output JSON Schema),detectOperationType(SSE response →Sub, GET →Query, elseMutation). Pub ops are not produced byfrom_openapi— OpenAPI has no representation for producer→consumer streaming in v1. - For each
(path, method, operation)inspec.paths, constructs aHandlerRegistration:spec.name= theoperationId(or a generated${method}_${path_parts}name ifoperationIdis absent — same normalization as the TSnormalizeOperationId).spec.namespace= theconfig.namespace(the importing deployment's name for the service, not the OpenAPI doc'sinfo.title).spec.op_type=Query/Mutation/Sub(detected asSubfrom 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= theErrorDefinitions 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).
- 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:
- Reads the call input (
serde_json::Value). - 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 fromcontext.capabilities(see No-Env-Vars below). - Body: the
bodyfield of the input (forMutation/Sub).
- URL path: substitutes path parameters (
- Sends the request via the shared HTTP client (see HTTP Client below).
- For a
Query/Mutation: parses the response body (JSON, text, or binary — same content-type branching as the TScreateHTTPOperation), wraps it in aResponseEnvelope, returns. Registered asHandlerKind::Once— aHandlerreturning a singleResponseEnvelope. - For a
Sub(text/event-streamresponse): streamscall.respondedevents as the SSE chunks arrive (same SSE parsing as the TSparseSSEFrames), then the stream ends on SSE close (which becomescall.completedon the wire). Registered asHandlerKind::Stream— aStreamingHandlerreturning aBoxStream<ResponseEnvelope>(alkcall ADR-021). Each SSEdata:frame becomes aResponseEnvelope::ok(); an HTTP error (non-2xx) becomes a singleResponseEnvelope::error()and ends the stream. The streaming send rides the shared HTTP client's stream client (see HTTP Client below): the same config minus the total request timeout (FWD-15) — a subscription is unbounded in time by contract (alkcall ADR-021 setsdeadline: None), so the outbound half must not die at the 30 s request deadline the request/response half carries. Time-unbounded does not mean memory-unbounded: the SSE parse loop enforces a total streamed-bytes cap per subscription (HttpClientConfig.stream_total_byte_cap, default 1 GiB), accumulated across every chunk fed to the parser (FWD-14); exceeding it terminates the stream with a single terminal error envelope (HTTP_413), the same stream-ends semantics as the other terminal arms. The bounded-time companion is the client's read timeout, which stays armed on the stream client: it bounds upstream staleness (no byte for 30 s → terminal), not stream lifetime. - On HTTP error (non-2xx): maps to the declared
ErrorDefinitionby HTTP status code (see Error Fidelity below), returns aCallError.
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 shared SharedHttpClient exposes two derived clients from one
config, rebuilt-and-swapped together (the same atomicity rule as the
config itself):
- Request client (
SharedHttpClient::client) — carries the total request timeout (default 30 s, anchored to the gateway's Once-op deadline). Every request/response forward (from_openapi/from_jsonschemaQueries and Mutations) sends through it. - Stream client (
SharedHttpClient::stream_client) — built from the same config with the total request timeout removed and the connect + read timeouts retained (FWD-15). Streaming (SSE) subscription forwards send through it. reqwest 0.13's per-request timeout override can lengthen a client-level total timeout but never clear it (the request-scopedNonefalls back to the client default), so a timeout-free subscription send requires the derived client rather than a request extension. Unbounded time per subscription is the contract (alkcall ADR-021); the complementary bounds are the read timeout (staleness) and the total streamed-bytes cap (see the forwarding-handler step 5 above).
Both clients carry the same middleware stack, redirect policy, and TLS trust — the total request timeout is the only delta between them.
The middleware stack has two layers:
RetryTransientMiddleware(fromreqwest-retry) — exponential backoff on transient failures (connection errors, 5xx). The "retry N times with increasing intervals" part. Configured via anExponentialBackoffpolicy at client construction.- Inlined
RetryAfterMiddleware— parses theRetry-Afterheader 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) frommelotic/reqwest-retry-after, not pulled as a dependency: the crate is complementary toreqwest-retry(whose default strategy does not honorRetry-After), and inlining lets the upstream's unboundedHashMap<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>, orAuthorization: ApiKey <key>— theHTTPServiceConfig.authin 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:
- Takes an
OperationSpec(name, op type, input/output JSON Schema,error_schemas,access_control,visibility), anHttpServiceConfig(base URL, auth scheme, default headers — the same config typefrom_openapiuses), a path template (e.g./users/{id}/posts), and an HTTP method (e.g.GET). - Builds one
HandlerRegistration:spec= the caller-suppliedOperationSpec(the caller already has the JSON Schemas; no parsing needed).handler= a reqwest forwarding handler, identical in shape tofrom_openapi's: builds the HTTP request (path-template substitution, query params, body), injects credentials fromcontext.capabilities, sends via the shared HTTP client, parses the response (JSON / text / binary — same content-type branching). ForSubop type, registers aStreamingHandler(alkcall ADR-021) expectingtext/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 asfrom_openapi).
- 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 the caller's callable operations (AccessControl-filtered). Items: name, namespace, op_type. |
/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 OperationContext →
OperationRegistry::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 are the shared
components-referenced status map: the protocol-level errors, plus any
operation-level errors (keyed by the registry's declared http_status.
Entries whose codes lack the HTTP_<status> prefix carry
x-runtime-behavior: 500 — the runtime mapper is purely code-driven,
see review-001 PRJ-04. Declarations at a protocol status from
HTTP_<status>-prefixed codes are merged oneOf into the shared
protocol response instead of overwriting it — the runtime emits both,
see review-002 PRJ-17):
# /call endpoint responses
responses:
'200': { $ref: '#/components/schemas/CallOk' } # envelope { request_id, result, output }
'400': { description: plain-text extractor rejection (framework body) }
'415': { description: plain-text extractor rejection (missing Content-Type, PRJ-19) }
'401': { oneOf: [CallErrorForbidden, CallErrorInvalidOperationType] } # identity split, PRJ-20
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' } # merged oneOf when ops declare HTTP_404 (PRJ-17)
'422': { oneOf: [CallErrorInvalidInput, CallErrorInvalidOperationType] } # + plain-text extractor shape rejection (PRJ-19)
'429': { ... } # only when ops declare errors at 429; x-runtime-behavior: 500 for non-HTTP_* codes
'500': { oneOf: [CallErrorInternal, CallFailure] } # INTERNAL + non-HTTP_* operation codes
'503': { ... } # only when ops declare errors at 503
'504': { $ref: '#/components/responses/Timeout' }
The operation-declared errors are surfaced on the /call and
/publish endpoints' responses — the gateway projects the registered
operations' 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. The /batch endpoint documents no
HTTP 500 (review-002 PRJ-21): every per-entry dispatch failure is an
in-band results[] entry; the only HTTP error status is the
request-level cap failure (400). BatchResultEntry.error references
the BatchError component (review-002 PRJ-16b): the serialized
CallError as a oneOf over the six protocol-code envelopes plus a
generic arm carrying the operation-declared codes. 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_mcphandlers read credentials fromOperationContext.capabilities, notstd::env::var. This is the no-env-vars invariant (ADR-014). The handler implementations are verified against this invariant.from_jsonschemashares this invariant — same handler shape, same credential path (ADR-066).from_openapi-registered ops areInternalby default. They are composition material, not directly callable from the wire (alkcall ADR-017). The handler that composes them isExternal.from_jsonschemaops areInternalby default for the same reason (ADR-066).from_openapierror codes are prefixedHTTP_<status>. No collision with protocol-level codes (alkcall ADR-016, review #002 W20).from_jsonschemashares this rule (ADR-066).from_openapiaccepts JSON and YAML;from_strdetects 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. Withyaml_serde0.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_strtries JSON first, falls back to YAML only if JSON parse fails.from_json/from_yamlare the explicit constructors for callers that know the format.to_openapiis a pure projection. It consumes the registry, does not produce entries for it. Not anOperationAdapter.to_openapioutput is JSON. The published gateway doc is served atGET /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_openapispecs are compatibility contracts. The generated gateway doc carriesinfo.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). alkhttpowns its HTTP client. Shared across all forwarding handlers, constructed once. The shared type isreqwest_middleware::ClientWithMiddleware(middleware stack:RetryTransientMiddleware+ inlinedRetryAfterMiddleware). 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.
- 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
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 ( |
ADR-036 → superseded by ADR-047 | POST /{service}/{op} → call.requested/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_openapipublished-spec versioning — resolved by ADR-045:info.versionsemver tracks the gateway endpoint contract (major = breaking gateway change, minor = additive, patch = wording); the per-caller operation set is discovered via/searchand 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+ inlinedRetryAfterMiddleware; 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
OperationAdaptertrait (inalkcall::client),to_*are projections - ADR-066 —
from_jsonschemaas HTTP-backed single-endpoint adapter inalkhttp(supersedes alkcall ADR-022 §5'sfrom_jsonschemaclause; 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.md—OperationAdaptertrait,AdapterErrorvariants (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-retrycrate (https://docs.rs/reqwest-retry/) —RetryTransientMiddleware/ExponentialBackoffretry policymelotic/reqwest-retry-after(https://github.com/melotic/reqwest-retry-after) —RetryAfterMiddlewaresource (MIT, inlined, not a dependency)