docs(arch): ADR-051 — YAML input format for from_openapi (gap-fill; JSON-first detection, yaml_serde dep)

from_openapi's spec already stated the contract as "JSON/YAML doc" but the
implementation only delivered the JSON half. vast.ai publishes a YAML
OpenAPI schema, which surfaces the gap. This is gap-filling against an
existing constraint — no architecture invariants touched (OpenAPISpec
stays serde_json::Value-based, forwarding handler / no-env-vars / error
fidelity / to_openapi output unchanged).

ADR-051 records: (1) from_yaml + format-detecting from_str constructors;
(2) JSON-first/YAML-fallback detection as a correctness rule (YAML 1.1
coerces yes/no/on/off to booleans — running JSON through YAML would
silently corrupt string fields); (3) yaml_serde dependency (maintained
official-YAML-org fork of deprecated serde_yaml, not feature-gated);
(4) to_openapi output stays JSON (scope boundary).

Specs updated to reference ADR-051 by number (http-adapters, overview,
both README ADR tables). Implementation task added at
tasks/http/adapters/from-openapi-yaml-input.md.
This commit is contained in:
glm-5.2 committed 2026-07-06 12:41:46 +00:00
1 parent d0e3711b46
commit 193f3a0e24
6 files changed
+420 -12

No files matched your search

+3 -2
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-05
last_updated: 2026-07-06
---
# Alknet Architecture
@@ -41,7 +41,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [crates/http/overview.md](crates/http/overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
| [crates/http/http-server.md](crates/http/http-server.md) | draft | HttpAdapter for h2/http1.1 + WebSocket upgrade route, axum over QUIC, Bearer auth, stealth, /healthz |
| [crates/http/websocket.md](crates/http/websocket.md) | draft | WebSocket browser bidirectional path — native `EventEnvelope` call-protocol session (not the gateway shape); framing, dispatch, bidirectionality, connection-local overlay, browsers-are-not-peers, deferred `from_wss` |
| [crates/http/http-adapters.md](crates/http/http-adapters.md) | draft | from_openapi (reqwest) and to_openapi (projection); no-env-vars injection point |
| [crates/http/http-adapters.md](crates/http/http-adapters.md) | draft | from_openapi (reqwest; JSON + YAML input per ADR-051) and to_openapi (projection); no-env-vars injection point |
| [crates/http/http-mcp.md](crates/http/http-mcp.md) | draft | from_mcp / to_mcp (feature-gated), streamable-HTTP-only, stdio exclusion |
| [crates/http/webtransport.md](crates/http/webtransport.md) | deferred | h3/WebTransport handler — deferred per ADR-044; browser bidirectional path uses WebSocket (see http-server.md). Spec kept intact for revival. |
| [crates/vault/README.md](crates/vault/README.md) | stable | alknet-vault crate index |
@@ -104,6 +104,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [048](decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | Accepted |
| [049](decisions/049-streaming-handler-for-subscriptions.md) | Streaming Handler for Subscription Operations | Accepted |
| [050](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership for Runtime-Spawned Resources | Accepted |
| [051](decisions/051-yaml-input-for-from-openapi.md) | YAML Input Format for from_openapi | Accepted |
## Open Questions
+4 -2
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-30
last_updated: 2026-07-06
---
# alknet-http
@@ -19,7 +19,7 @@ protocol), and hosts the HTTP-backed call-protocol adapters
| [overview.md](overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
| [http-server.md](http-server.md) | draft | `HttpAdapter` (`ProtocolHandler` for `h2`/`http/1.1` + WS upgrade route), axum over QUIC, Bearer auth, stealth, `/healthz`; WS hands off to the native session spec |
| [websocket.md](websocket.md) | draft | WebSocket browser bidirectional path — native `EventEnvelope` call-protocol session (not the gateway shape, ADR-048); framing, dispatch, bidirectionality, connection-local Layer 2 overlay, browsers-are-not-peers rationale, streaming (native `call.responded`, no SSE), deferred `from_wss` adapter |
| [http-adapters.md](http-adapters.md) | draft | `from_openapi` (reqwest client) and `to_openapi` (OpenAPI projection); no-env-vars invariant point |
| [http-adapters.md](http-adapters.md) | draft | `from_openapi` (reqwest client; JSON + YAML input per ADR-051) and `to_openapi` (OpenAPI projection); no-env-vars invariant point |
| [http-mcp.md](http-mcp.md) | draft | `from_mcp` / `to_mcp` (feature-gated), streamable-HTTP-only, stdio exclusion |
| [webtransport.md](webtransport.md) | deferred | `h3`/WebTransport handler — **deferred per ADR-044**; spec kept intact for revival |
@@ -53,6 +53,8 @@ protocol), and hosts the HTTP-backed call-protocol adapters
| [046](../../decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | `extra_routes: Option<Router>` at construction; deployments add raw HTTP endpoints (e.g., OAI-compatible proxy, or a REST-like per-operation projection) that coexist with the default surface; default surface takes precedence on collision |
| [047](../../decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | `POST /{service}/{op}` direct-call surface removed; the 5 gateway endpoints are the sole invoke path; per-caller `AccessControl`-filtered `/search` is the discovery; ADR-036's non-routing clauses survive |
| [048](../../decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | WS is the native `EventEnvelope` session; the gateway endpoints (`/search`/`/schema`/`/call`/`/batch`/`/subscribe`) are HTTP-only and do not appear on WS; discovery via `services/list`/`services/schema` as call-protocol ops |
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | Streaming Handler for Subscription Operations | `from_openapi` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`); SSE → `BoxStream<ResponseEnvelope>` |
| [051](../../decisions/051-yaml-input-for-from-openapi.md) | YAML Input Format for from_openapi | `from_openapi` accepts JSON and YAML (`from_json`/`from_yaml`/`from_str`); `from_str` is JSON-first/YAML-fallback (correctness rule, §2); YAML dep is `yaml_serde` (maintained fork of deprecated `serde_yaml`); `to_openapi` output stays JSON (out of scope, §4) |
## Relevant Open Questions
+38 -7
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-02
last_updated: 2026-07-06
---
# HTTP Adapters — from_openapi and to_openapi
@@ -46,15 +46,27 @@ impl OperationAdapter for FromOpenAPI {
#### Type definitions
```rust
/// A parsed OpenAPI document. The concrete type is a two-way-door
/// implementation detail (openapiv3::OpenApi, a local alknet-http
/// type, or a serde_json::Value-based parse); the one-way constraint is
/// that `from_openapi` accepts a standard OpenAPI 3.x JSON/YAML doc and
/// 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 5-endpoint gateway doc (ADR-042). 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 (correctness
/// rule — ADR-051 §2).
pub struct OpenAPISpec {
pub info: OpenAPIInfo,
pub paths: BTreeMap<String, PathItem>,
@@ -62,6 +74,13 @@ pub struct OpenAPISpec {
// ... 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
@@ -85,8 +104,10 @@ pub enum HttpAuthScheme {
The adapter:
1. Parses the OpenAPI document (`OpenAPISpec` — `paths`, `components`,
`$ref` resolution). On parse failure, returns
`AdapterError::SchemaParse`. The TS prior art
`$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 correctness rule — 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),
@@ -396,8 +417,17 @@ once published, the 5-endpoint gateway shape is one-way.
The handler that composes them is `External`.
- **`from_openapi` error codes are prefixed `HTTP_<status>`.** No
collision with protocol-level codes (ADR-023, review #002 W20).
- **`from_openapi` accepts JSON and YAML; `from_str` detects format
JSON-first.** JSON-first is a correctness rule (ADR-051 §2), not a
style preference. `from_str` tries JSON first (strict grammar), 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
@@ -432,6 +462,7 @@ once published, the 5-endpoint gateway shape is one-way.
| `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 |
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_openapi` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`); SSE response → `BoxStream<ResponseEnvelope>`; `Query`/`Mutation` stay `HandlerKind::Once` |
| YAML input + JSON-first format detection | [ADR-051](../../decisions/051-yaml-input-for-from-openapi.md) | `from_openapi` accepts JSON and YAML (`from_json`/`from_yaml`/`from_str`); `from_str` is JSON-first/YAML-fallback (correctness rule, §2); YAML dep is `yaml_serde`; `to_openapi` output stays JSON (out of scope, §4) |
## Open Questions
+8 -1
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-30
last_updated: 2026-07-06
---
# alknet-http — Overview
@@ -90,6 +90,7 @@ alknet-http
├── axum (HTTP server — Router, extractors, middleware, WebSocket upgrade)
├── reqwest (HTTP client — from_openapi/from_mcp forwarding)
├── hyper (HTTP/1.1 + HTTP/2 framing; axum is built on hyper)
├── yaml_serde (YAML parse for from_openapi YAML input — ADR-051; maintained fork of serde_yaml)
└── rmcp (MCP streamable HTTP — feature-gated behind `mcp`)
```
@@ -199,6 +200,12 @@ browser bidirectional path is WebSocket, native to axum, no `h3` feature
gate needed. A deployment that wants MCP tool import/export enables
`mcp`.
**`yaml_serde` is not feature-gated** — YAML OpenAPI is a first-class
input format (vast.ai publishes a YAML schema), not an edge case. A
deployment that imports a YAML-publishing provider must not have to
remember a feature flag (ADR-051 §3). The dependency is small (pure
Rust, no native code), consistent with the default-features philosophy.
## The No-Env-Vars Invariant
The `from_openapi`/`from_mcp` forwarding handlers are the **credential
@@ -0,0 +1,187 @@
# ADR-051: YAML Input Format for from_openapi
## Status
Accepted
## Context
`from_openapi` imports external HTTP APIs as call-protocol operations by
parsing an OpenAPI document. The `http-adapters.md` spec already states the
one-way constraint as "`from_openapi` accepts a standard OpenAPI 3.x
JSON/YAML doc" — YAML was always part of the intended input contract. The
implementation, however, only ever delivered the JSON half:
`OpenAPISpec::from_json(&str)`. This was fine for providers that publish JSON
OpenAPI schemas (e.g., runpod's `openapi.json`) but blocks providers that
publish YAML schemas (e.g., vast.ai's `openapi.yaml`). A coming consumer crate
needs to import vast.ai's operations, which surfaces the gap.
This is gap-filling against an existing constraint, not a new architectural
direction. None of the architecture invariants are touched: `OpenAPISpec`
stays `serde_json::Value`-based, the forwarding handler is unchanged, the
no-env-vars credential injection is unchanged, error fidelity is unchanged,
`to_openapi` output stays JSON. The change is a new parse path into the
same internal type.
Two decisions need recording: the parse strategy (JSON-first, not
parse-everything-as-YAML) and the dependency choice (the maintained
`yaml_serde` fork, not the deprecated `serde_yaml`).
## Decision
### 1. `from_openapi` accepts YAML via a `from_yaml` constructor and a format-detecting `from_str`
`OpenAPISpec` gains two constructors alongside the existing `from_json`:
```rust
impl OpenAPISpec {
pub fn from_json(doc: &str) -> Result<Self, AdapterError>; // existing
pub fn from_yaml(doc: &str) -> Result<Self, AdapterError>; // new — parses YAML
pub fn from_str(doc: &str) -> Result<Self, AdapterError>; // new — detects format
pub fn from_value(raw: Value) -> Result<Self, AdapterError>; // existing, unchanged
}
```
`from_str` is the convenience for callers that have a raw doc string of
unknown format (e.g., fetched from a URL with no Content-Type hint). The
detection rule is **JSON-first, YAML-fallback** (see §2 for why the order
matters): attempt `serde_json::from_str`; if it parses, use the result; if
it fails, attempt YAML. `from_json` and `from_yaml` remain for callers that
know the format and want a precise error on mismatch.
This is an additive API surface change (two-way door — constructors can be
renamed/added; nothing downstream breaks). The constructors produce the same
`OpenAPISpec`; the rest of the adapter is format-agnostic.
### 2. Format detection is JSON-first, YAML-fallback — a correctness rule, not a style preference
YAML 1.1 (the version the maintained Rust YAML crates implement — see §3)
treats the bare tokens `yes`/`no`/`on`/`off`/`y`/`n` as booleans, and `1.0`
as a float. OpenAPI specs routinely have **string** fields with these values
(e.g., a query parameter named `active` with value `"yes"`, or an enum of
`["on", "off", "auto"]`). If a JSON document is parsed through the YAML
parser, `{"active": "yes"}` silently becomes `{"active": true}` — the
string is lost, the schema is wrong, and the failure is silent (no error,
just a mutated value that the forwarding handler then sends to the external
API as a boolean where a string was expected).
This is a correctness footgun, not a matter of error-message quality.
`from_str` therefore tries JSON first: a JSON parse succeeds for any valid
JSON document, and JSON's stricter grammar (`"yes"` is a string, `true` is a
boolean — no ambiguity) avoids the YAML 1.1 coercion. Only if JSON parse
fails does `from_str` fall back to YAML. A YAML-only document (with `openapi:
3.0.0` at the top, no JSON braces) fails JSON parse immediately and goes to
the YAML path. The net result: JSON docs are never exposed to YAML's type
coercion, and YAML docs are parsed as YAML.
`from_yaml` (the explicit constructor) does not try JSON first — the caller
has declared the format. This is correct: a caller that explicitly says
"this is YAML" wants the YAML parse, including its coercion rules. If the
caller is wrong (passes JSON to `from_yaml`), the YAML parser handles it —
JSON is a syntactic subset of YAML, so it parses, but with the same
silent string→boolean coercion as the `from_str` footgun above
(`{"active": "yes"}` becomes `{"active": true}`, no error). The caller
opted in by naming the format; `from_str` exists for the unsure caller.
### 3. The YAML dependency is `yaml_serde` (the official YAML org fork of `serde_yaml`), not the deprecated `serde_yaml`
The original `serde_yaml` crate (dtolnay) is no longer maintained. The
official [YAML organization](https://github.com/yaml) maintains a
continuation published as `yaml_serde` (crate name `yaml_serde`, v0.10), a
drop-in fork with full API compatibility. The migration path is either
`serde_yaml = { package = "yaml_serde", version = "0.10" }` (keeps
`use serde_yaml::` imports) or direct `yaml_serde = "0.10"` with updated
imports. `alknet-http` uses the direct form (`yaml_serde = "0.10"`,
`use yaml_serde::`).
The dependency is a two-way door: `yaml_serde` can be swapped for another
maintained YAML-serde fork (or a future replacement) by changing the
Cargo line and the imports. The one-way constraint is that `alknet-http`
owns its YAML parse and produces `serde_json::Value` (the shared internal
type) — which dependency does the parse is an implementation detail.
`yaml_serde` is chosen because it is the maintained continuation under
the official YAML umbrella, not because its API is irreversibly
load-bearing.
The dependency is **not feature-gated**. YAML OpenAPI schemas are a
first-class input format (vast.ai publishes one), not an edge case. Gating
it behind a feature would mean a deployment that imports vast.ai must
remember to enable the feature — the kind of friction the no-surprises
default-features model avoids. The dependency is small (a pure-Rust YAML
parser, no native code), consistent with the existing default-features
philosophy of the crate.
### 4. Scope boundary: `to_openapi` output is not affected
`to_openapi` generates the published gateway doc, served at `GET
/openapi.json`. It stays JSON. This ADR fills a gap on the *consume* side
(importing external YAML schemas); the *publish* side serves our own
gateway contract and JSON is the standard exchange format for OpenAPI
tooling (code generators, validators, `fetch`-based clients all consume
JSON). A `GET /openapi.yaml` additive output is not part of this decision:
it is a separate scope (publish-side format, not consume-side), would be a
separate ADR if a concrete consumer requires YAML output, and is
additive (a new endpoint, no breaking change to the JSON path). The
`OpenAPISpec` type is shared, but the output serialization is JSON-only.
## Consequences
**Positive:**
- `from_openapi` consumes both JSON and YAML OpenAPI schemas — the
intended contract (spec line: "JSON/YAML doc") is finally delivered. vast.ai
and any other YAML-publishing provider can be imported.
- Format detection (`from_str`) makes fetch-and-import ergonomic: a caller
that fetched a schema from a URL with no reliable Content-Type doesn't
have to sniff the format itself.
- JSON-first detection prevents the YAML 1.1 boolean-coercion footgun from
silently corrupting JSON docs. The rule is a correctness guard, not
cosmetic.
- The maintained `yaml_serde` fork keeps the dependency off the archived
`serde_yaml`; the swap is documented so a future maintainer doesn't
re-derive why the crate name doesn't match the obvious name.
**Negative:**
- A new pure-Rust dependency (`yaml_serde`) in `alknet-http`. Small, but
non-zero. The trade is first-class YAML support without a feature gate —
accepted because YAML OpenAPI is a real input format, not an edge case.
- `from_str`'s JSON-first detection does one wasted parse attempt for YAML
docs (the JSON parse fails, then the YAML parse runs). The cost is
trivial — `from_openapi` runs once at adapter-import time (not per
forwarded call), so the double-parse happens once per imported service,
not per request. Callers that know the format use `from_json`/`from_yaml`
directly and pay no double-parse. The correctness benefit (never running
JSON through YAML coercion) is worth the one-time cost.
## Assumptions
1. **The `OpenAPISpec` internal type stays `serde_json::Value`-based.** YAML
parses to `serde_json::Value` via `yaml_serde`, then feeds the existing
`from_value` path. No second internal representation. If a future
switch to `openapiv3::OpenApi` happens (the two-way-door the spec already
notes), both JSON and YAML constructors adapt in lockstep — the
constructor is the adapter between wire format and internal type.
2. **YAML 1.1 coercion is the only material difference for OpenAPI parsing.**
YAML 1.2 (where JSON is a true subset and `yes`/`no` are strings) would
make parse-everything-as-YAML safe. The maintained Rust crates are YAML
1.1; this ADR's JSON-first rule is the workaround. If a YAML 1.2 Rust
parser becomes the obvious choice later, the JSON-first rule becomes a
style preference rather than a correctness guard — but the rule stays
(no reason to run JSON through a YAML parser).
## References
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) —
`from_openapi` is an `OperationAdapter`; published `to_*` specs are
compatibility contracts (the publish side stays JSON)
- [ADR-023](023-operation-error-schemas.md) — error fidelity is unaffected
(error schemas come from the parsed `OpenAPISpec`, format-independent)
- [ADR-039](039-http-server-and-client-host-colocated.md) — `alknet-http`
owns both HTTP directions and their dependencies
- [http-adapters.md](../crates/http/http-adapters.md) — the spec that
states the "JSON/YAML doc" constraint, the `OpenAPISpec` type, and the
Constraints/Design Decisions entries this ADR backs (see the
"Input formats" doc-comment, the Constraints §"`from_openapi` accepts
JSON and YAML", and the Design Decisions table row)
- `yaml_serde` crate (https://github.com/yaml/yaml-serde) — the maintained
official-YAML-org fork of the deprecated `serde_yaml`