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 a1e313f..c23e965 100644 --- a/docs/architecture/decisions/051-yaml-input-for-from-openapi.md +++ b/docs/architecture/decisions/051-yaml-input-for-from-openapi.md @@ -133,6 +133,79 @@ 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. +### 5. YAML/JSON parity contract (review 002 OAI-12) + +> **Amendment (2026-08-30, review 002 OAI-12):** the original delivery fed +> `yaml_serde::from_str::` straight into `from_value` +> with zero post-parse normalization, and three verified corruptions +> flowed through unimpeded: duplicate keys silently last-won; `.inf`/ +> `.nan` scalars silently became `null` (`serde_json::Number::from_f64` +> is `None` for non-finite floats); merge keys (`<<: *anchor`) survived +> as literal `<<` properties. `from_yaml` now runs a normalization +> pipeline; this section records the resulting contract. The same +> document may still mean different things through `from_json` and +> `from_yaml` only where explicitly stated below — every difference is +> loud on the YAML side. + +The `from_yaml` pipeline is: an explicit parse into `yaml_serde::Value` +→ [`apply_merge()`](https://docs.rs/yaml_serde) → one structural +normalization pass into `serde_json::Value` → the shared `from_value` +path. Per-construct rules: + +1. **Duplicate mapping keys are rejected loudly on the YAML path** — the + explicit `yaml_serde::Value` parse rejects them natively with the key + and its line/column, naming the document position. The JSON path + *silently last-wins* (`serde_json::Value`'s `visit_map` inserts into + a map; verified empirically against serde_json 1.0.151 — the + oft-assumed "JSON errors on duplicates" behavior does not hold for + the `Value` target). The YAML path is deliberately the stricter one: + a duplicate-key document fails YAML import instead of silently + meaning different things through the two entry points. Callers who + need JSON-path tolerance of duplicates have it by construction; the + asymmetry is documented and tested, not hidden. +2. **Non-finite floats are rejected loudly.** YAML 1.2 core schema has + `.inf`/`-.inf`/`.INF`/`.nan` (and case variants) as floats; + `serde_json::Number` cannot represent them, and an unguarded + conversion would advertise `null` where the document declares e.g. + `maximum: .inf` — silently dropping a declared constraint from the + schema. The normalization pass fails import with the offending + value's JSON pointer and the rendered value. +3. **Merge keys are applied, not advertised.** `<<: *anchor` resolves + per the YAML merge type before conversion; `<<` never survives as a + literal property in the parsed spec. The semantics are `yaml_serde`'s + `apply_merge()`: shallow `entry().or_insert()` into the referencing + mapping — the referencing mapping's explicit keys win, and a `<<` + value that is neither a mapping nor a sequence of mappings fails + loudly. This is the one deliberate, tested deviation from strict + YAML 1.2 core-schema processing (§2): merge keys are a YAML 1.1 + mechanism that YAML tooling and authors universally expect to work, + and the alternative (rejecting `<<` loudly) would refuse + anchor-heavy real-world documents that JSON cannot even express. A + producer wanting strict 1.2 processing can avoid `<<` in favor of + explicit repetition. +4. **Non-string mapping keys** are stringified on both paths by + construction (JSON keys are strings; YAML scalar keys are rendered + exactly as the YAML 1.2 core schema renders the scalar: `200:` → + `"200"` matches JSON `{"200": ...}`, `true:` → `"true"`, `1.5:` → + `"1.5"`). Keys with no round-trip-stable string form — nullish keys + (`~`, empty) and collection keys (sequences, mappings) — are + rejected loudly with the key's JSON pointer instead of being + stringified through YAML's debug rendering. +5. **Scalar interpretation is identical by dependency version.** Both + paths go through the YAML 1.2 core schema or JSON's stricter grammar + (§2 as amended): bare `yes`/`no`/`on`/`off` are strings, `!!str 200` + is the string `"200"`, and unknown tags fail loudly on both paths. + The JSON-first rule of §2 remains the defensive lock against a + future YAML-parser swap changing this. + +The normalization pass adds no new resource bounds: it walks structure +already accepted by `yaml_serde`'s parse-time limits (recursion limit, +alias jump limit, repetition limit — re-verified during review 002), is +linear in document size, and allocates only the converted document +plus error messages. Fixtures for the normalization are all linear or +bounded; the `$ref` resolver's budgets (OAI-01/OAI-11) are downstream +and unchanged. + ### 4. Scope boundary: `to_openapi` output is not affected `to_openapi` generates the published gateway doc, served at `GET