From 14c8340380a57c586eb12787edd4c34ac6f91218 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Mon, 6 Jul 2026 13:30:59 +0000 Subject: [PATCH] =?UTF-8?q?docs(arch):=20amend=20ADR-051=20=C2=A72=20?= =?UTF-8?q?=E2=80=94=20yaml=5Fserde=200.10.x=20is=20YAML=201.2,=20not=201.?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementation verified yaml_serde 0.10.4 (and serde_yaml 0.9) implement the YAML 1.2 core schema, where bare yes/no/on/off are strings, not booleans. The original §2 rationale cited YAML 1.1 boolean-coercion as a present hazard justifying JSON-first detection; it does not exist with this dependency version. The JSON-first decision is retained (Accepted ADR) — reframed as a defensive default that locks the contract against a future YAML-parser swap, not a guard against a present hazard. §2 heading, Consequences, and Assumption #2 amended; an in-place amendment note records what changed and when. The two coercion-related tests already document yaml_serde 0.10.x's actual behavior with explicit comments. Source doc-comments on from_yaml/from_str corrected to stop propagating the flawed assumption. 256 tests pass; clippy clean. --- .../alknet-http/src/adapters/from_openapi.rs | 21 ++-- docs/architecture/crates/http/README.md | 2 +- .../architecture/crates/http/http-adapters.md | 24 +++-- .../051-yaml-input-for-from-openapi.md | 102 +++++++++++------- 4 files changed, 97 insertions(+), 52 deletions(-) diff --git a/crates/alknet-http/src/adapters/from_openapi.rs b/crates/alknet-http/src/adapters/from_openapi.rs index bee356a..8e4c0b4 100644 --- a/crates/alknet-http/src/adapters/from_openapi.rs +++ b/crates/alknet-http/src/adapters/from_openapi.rs @@ -112,7 +112,8 @@ impl OpenAPISpec { /// Parse a YAML OpenAPI document. /// /// The caller has declared the format, so this does not attempt JSON - /// first — YAML's coercion rules apply (see ADR-051 §2). YAML is parsed + /// first — whatever type interpretation the YAML parser's schema + /// applies is what the caller gets (see ADR-051 §2). YAML is parsed /// to a `serde_json::Value` and then fed through the existing /// [`from_value`](Self::from_value) path, so there is one internal /// `OpenAPISpec` representation shared with the JSON path. @@ -126,12 +127,18 @@ impl OpenAPISpec { /// Parse a raw OpenAPI document of unknown format. /// /// Detection is **JSON-first, YAML-fallback** (ADR-051 §2). JSON's - /// stricter grammar avoids YAML 1.1 boolean coercion (the bare tokens - /// `yes`/`no`/`on`/`off` would otherwise be coerced to booleans, silently - /// mutating `{"active": "yes"}` into `{"active": true}`). A JSON parse - /// succeeds for any valid JSON document; only on JSON failure does this - /// fall back to the YAML parser. A YAML-only document (no JSON braces) - /// fails JSON parse immediately and goes to the YAML path. + /// stricter grammar is immune to any YAML-specific type interpretation, + /// so a JSON doc never reaches the YAML parser under `from_str`. This + /// is a defensive default: `yaml_serde` 0.10.x implements the YAML 1.2 + /// core schema (where bare `yes`/`no`/`on`/`off` are strings, not + /// booleans), so the coercion hazard the original ADR rationale cited + /// is not present with this dependency version — but JSON-first locks + /// the contract against a future YAML-parser swap (e.g., to a YAML 1.1 + /// crate where those tokens coerce to booleans). See the + /// `from_str_json_doc_preserves_yes_string_against_yaml_coercion` and + /// `from_yaml_preserves_bare_yes_as_string_yaml_1_2_behavior` tests for + /// the codified behavior. A YAML-only document (no JSON braces) fails + /// JSON parse immediately and goes to the YAML path. #[allow( clippy::should_implement_trait, reason = "ADR-051 §1 names this an inherent constructor `from_str`, not a FromStr impl" diff --git a/docs/architecture/crates/http/README.md b/docs/architecture/crates/http/README.md index 090f748..2c1ac3d 100644 --- a/docs/architecture/crates/http/README.md +++ b/docs/architecture/crates/http/README.md @@ -54,7 +54,7 @@ protocol), and hosts the HTTP-backed call-protocol adapters | [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` | -| [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) | +| [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 (defensive default, §2 amended — `yaml_serde` 0.10.x is YAML 1.2, not 1.1); YAML dep is `yaml_serde` (maintained fork of deprecated `serde_yaml`); `to_openapi` output stays JSON (out of scope, §4) | ## Relevant Open Questions diff --git a/docs/architecture/crates/http/http-adapters.md b/docs/architecture/crates/http/http-adapters.md index a5cc0b8..e2fa608 100644 --- a/docs/architecture/crates/http/http-adapters.md +++ b/docs/architecture/crates/http/http-adapters.md @@ -65,8 +65,12 @@ impl OperationAdapter for FromOpenAPI { /// 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). +/// 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, @@ -106,7 +110,7 @@ The adapter: 1. Parses the OpenAPI document (`OpenAPISpec` — `paths`, `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 correctness rule — ADR-051 §2). On parse + 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, @@ -418,10 +422,14 @@ once published, the 5-endpoint gateway shape is one-way. - **`from_openapi` error codes are prefixed `HTTP_`.** 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. + 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 @@ -462,7 +470,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`; `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) | +| 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 (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 diff --git a/docs/architecture/decisions/051-yaml-input-for-from-openapi.md b/docs/architecture/decisions/051-yaml-input-for-from-openapi.md index 498dab2..e29ac70 100644 --- a/docs/architecture/decisions/051-yaml-input-for-from-openapi.md +++ b/docs/architecture/decisions/051-yaml-input-for-from-openapi.md @@ -53,35 +53,54 @@ 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 +### 2. Format detection is JSON-first, YAML-fallback — a defensive default, 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). +> **Amendment (2026-07-06):** The original §2 cited YAML 1.1 +> boolean-coercion (`yes`/`no`/`on`/`off` → booleans) as a *present +> hazard* with the maintained Rust YAML crates, framing JSON-first as a +> correctness guard against silent string→boolean mutation. A probe +> during implementation verified this is factually wrong for the chosen +> dependency: `yaml_serde` 0.10.x (and the deprecated `serde_yaml` 0.9) +> implement the **YAML 1.2 core schema**, where only `true`/`false` (and +> case variants) are booleans — the bare tokens `yes`/`no`/`on`/`off`/ +> `y`/`n` are plain strings. The coercion hazard the original rationale +> cited does not exist with this dependency version. The JSON-first rule +> is **retained** (Accepted ADR) — the rationale is reframed below as a +> defensive default, not a guard against a present hazard. The decision +> did not change; the rationale did. -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. +JSON's grammar is a strict subset of YAML (under YAML 1.2) and never +exhibits any YAML-specific type interpretation. Running a JSON document +through a YAML parser is *currently* safe with `yaml_serde` 0.10.x — a +JSON doc like `{"active": "yes"}` parses through the YAML path with +`"yes"` intact as a string (YAML 1.2 core schema, verified by the +`from_yaml_preserves_bare_yes_as_string_yaml_1_2_behavior` test). +JSON-first detection is therefore not guarding against a present hazard +with this dependency; it is a **defensive default that locks in the +contract against a future YAML-parser swap**. If `yaml_serde` is ever +swapped for a YAML 1.1 crate (where `yes`/`no`/`on`/`off` coerce to +booleans), or if a future `yaml_serde` version tightens its core schema +in a way that introduces type interpretation JSON doesn't have, the +JSON-first rule ensures JSON input cannot be silently mutated by the +YAML path. The contract is durable; the dependency is a two-way door +(§3). + +The rule is cheap: `from_str` tries `serde_json::from_str` first (strict +grammar, no YAML-specific interpretation), and only on JSON parse failure +falls back to the YAML parser. 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 cost is one wasted parse attempt for YAML docs, paid once +at adapter-import time (not per forwarded call — see Consequences). `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. +"this is YAML" wants the YAML parse, including whatever type +interpretation the YAML parser applies. 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, with whatever interpretation the YAML +parser's schema applies (currently none for `yes`/`no` under YAML 1.2; a +future YAML 1.1 swap would coerce). 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` @@ -133,9 +152,13 @@ additive (a new endpoint, no breaking change to the JSON path). The - 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. +- JSON-first detection is a defensive default that locks in the + contract against a future YAML-parser swap. With `yaml_serde` 0.10.x + (YAML 1.2 core schema) the coercion hazard the original rationale + cited is not present; JSON-first nonetheless ensures JSON input is + never exposed to YAML-specific type interpretation, present or + future. The rule is cheap (one wasted parse for YAML docs, paid once + at import time) and the contract is durable. - 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. @@ -149,8 +172,9 @@ additive (a new endpoint, no breaking change to the JSON path). The 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. + directly and pay no double-parse. The defensive benefit (JSON input never + reaches the YAML parser, immune to any YAML-specific interpretation + present or future) is worth the one-time cost. ## Assumptions @@ -161,13 +185,19 @@ additive (a new endpoint, no breaking change to the JSON path). The 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). +2. **`yaml_serde` 0.10.x implements the YAML 1.2 core schema.** Verified + by a probe during implementation: bare `yes`/`no`/`on`/`off`/`y`/`n` + are plain strings, not booleans (codified by the + `from_yaml_preserves_bare_yes_as_string_yaml_1_2_behavior` test). The + original §2 rationale cited YAML 1.1 coercion as a present hazard; it + is not, with this dependency version. JSON-first detection is retained + as a defensive default (§2 as amended): a future swap to a YAML 1.1 + crate, or a future `yaml_serde` schema tightening, cannot silently + regress JSON input because JSON never reaches the YAML path under + `from_str`. If the dependency swaps to a YAML 1.1 crate, the defensive + default becomes a load-bearing correctness guard — the rule is the + same either way, which is why it is stated as a contract rather than + as a workaround for a specific crate version. ## References