docs(adr-051): YAML/JSON parity contract for the OAI-12 normalization pipeline

§5 records the post-OAI-12 from_yaml contract: duplicate keys rejected
loudly on YAML (with the empirical correction that serde_json 1.0.151's
Value path last-wins rather than errors — the YAML side is the stricter
one), non-finite floats rejected with JSON-pointer context, merge keys
applied via apply_merge (shallow, referencing keys win; the one
deliberate YAML 1.2 deviation), scalar-key stringification matching the
core schema, and the no-new-bounds note (the walk stays inside
yaml_serde's parse-time limits).

Verification: cargo doc --no-deps clean; module-doc cross-check in
openapi_spec.rs matches this contract.
This commit is contained in:
2026-08-30 22:38:04 +00:00
parent 1728b4881e
commit 67ae7cd9fe
@@ -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::<serde_json::Value>` 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