Skip to main content

phoxal_runtime_contract/
wire_schema.rs

1//! The wire shapes Phoxal's process-boundary contracts actually put on the
2//! wire, as data.
3//!
4//! This model exists so compatibility CI can prove a process/wire contract
5//! against a published baseline instead of trusting a reviewer to notice. A
6//! [`WireSchema`] describes the **serialized** representation one type
7//! produces: the map keys, the tag, the element order. It is not the Rust
8//! declaration it was written from, and not the predicates a decoder
9//! additionally enforces.
10//!
11//! Three properties make that claim usable:
12//!
13//! - **It models the real serde representation.** Every shape here corresponds
14//!   to something a `Serializer` is actually told to write: a struct is a map
15//!   of serialized field names, an externally tagged enum is a one-key map, an
16//!   internally tagged enum merges its tag into the body. Nothing is inferred
17//!   from a type name.
18//! - **An unsupported serde feature fails loudly.** `#[derive(DescribeWire)]`
19//!   refuses any attribute whose serialized shape it cannot compute -
20//!   `flatten`, adjacent tagging, an arbitrary `serialize_with` - with a
21//!   compile error naming the remedy. The schema can never silently
22//!   approximate, because a type it cannot model has no impl at all.
23//! - **Its rendering is canonical.** [`WireSchema::canonical_json`] emits
24//!   sorted-key, whitespace-free JSON, and struct fields and tagged-enum
25//!   variants are held in serialized-name order. Two structurally identical
26//!   declarations written in different orders render byte-identical, so string
27//!   equality is a meaningful comparison. The structure is kept, never hashed
28//!   away, so a later checker can name exactly which field or variant moved.
29//!
30//! Field and tagged-variant order is normalized because every Phoxal codec is
31//! name-addressed: the bus encodes MessagePack with named fields
32//! (`rmp_serde::to_vec_named`) and every persisted document is JSON. Reordering
33//! a struct's fields is therefore not a wire change, and the canonical form
34//! says so. Three orders *are* wire facts and survive exactly: tuple, sequence,
35//! and array elements, and an untagged enum's variants, which a decoder tries
36//! in declaration order and resolves to the first that accepts the value.
37//!
38//! # Decode-side narrowing is deliberately not shape
39//!
40//! `#[serde(try_from = "…")]`, `#[serde(from = "…")]`, and a field's
41//! `deserialize_with` change *which* wire values a decoder accepts - a finite
42//! float, a canonical yaw, a bounded string - not what an encoder writes. This
43//! module models the shape, so those are recorded nowhere and are not a
44//! compatibility axis here. `#[serde(into = "…")]` is different: it replaces
45//! the serialized form outright, so the schema delegates to the named type.
46//!
47//! # Generator evolution
48//!
49//! Within one compatibility line, a change to this machinery must not alter the
50//! rendered output for an unchanged contract. The canonical rendering is part
51//! of the promise: if a refactor here moved bytes, every baseline comparison
52//! would report a break that never happened. Change the rendering only together
53//! with a deliberate re-baseline.
54
55use std::collections::{BTreeMap, BTreeSet};
56use std::num::{NonZeroU32, NonZeroU64};
57
58/// One serialized wire shape.
59///
60/// The variants are exactly the serde data-model shapes this workspace's
61/// process contracts reach for. A shape serde can express but Phoxal never
62/// writes is deliberately absent: an author who needs one adds it here on
63/// purpose rather than getting an approximation.
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub enum WireSchema {
66    Bool,
67    U8,
68    U16,
69    U32,
70    U64,
71    I8,
72    I16,
73    I32,
74    I64,
75    F32,
76    F64,
77    String,
78    /// A byte string written through `serialize_bytes`, which every Phoxal
79    /// codec keeps distinct from a sequence of integers.
80    Bytes,
81    /// The unit value: `null` in JSON, nil in MessagePack.
82    Unit,
83    /// A schema-carrying document whose interior is genuinely open, such as an
84    /// embedded JSON Schema. Nothing below this point is a compatibility axis.
85    Freeform,
86    Option(Box<WireSchema>),
87    Seq(Box<WireSchema>),
88    Map {
89        key: Box<WireSchema>,
90        value: Box<WireSchema>,
91    },
92    /// A fixed-arity heterogeneous sequence. Element order is significant.
93    Tuple(Vec<WireSchema>),
94    /// A fixed-length homogeneous sequence. Element order is significant.
95    Array {
96        element: Box<WireSchema>,
97        length: usize,
98    },
99    /// A map of serialized field names, held in field-name order.
100    Struct {
101        fields: Vec<WireField>,
102    },
103    /// A newtype struct, which serde writes as its inner value with no
104    /// wrapping at all.
105    Newtype(Box<WireSchema>),
106    /// A sum type. A tagged one holds its variants in variant-name order; an
107    /// untagged one holds them in declaration order, because that order is what
108    /// decides which variant an ambiguous value resolves to.
109    Enum {
110        representation: EnumRepresentation,
111        variants: Vec<WireVariant>,
112    },
113    /// A type whose `Serialize` is hand-written, together with the wire form
114    /// that implementation declares.
115    ///
116    /// The name says which implementation owns the form, so a checker can
117    /// point at the hand-maintained boundary rather than at an anonymous
118    /// string somewhere inside a document.
119    Opaque {
120        name: String,
121        wire: Box<WireSchema>,
122    },
123}
124
125/// One serialized field of a struct or struct variant.
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub struct WireField {
128    /// The serialized name, after `rename` and `rename_all` have been applied.
129    pub name: String,
130    pub schema: WireSchema,
131    pub presence: FieldPresence,
132}
133
134impl WireField {
135    /// Declare one field.
136    #[must_use]
137    pub fn new(name: impl Into<String>, schema: WireSchema, presence: FieldPresence) -> Self {
138        Self {
139            name: name.into(),
140            schema,
141            presence,
142        }
143    }
144
145    /// Declare a field that is always written and always required.
146    #[must_use]
147    pub fn required(name: impl Into<String>, schema: WireSchema) -> Self {
148        Self::new(name, schema, FieldPresence::Required)
149    }
150}
151
152/// Whether a field may be absent from the wire form, and on which side.
153///
154/// Both halves are compatibility facts: a reader that stops accepting an absent
155/// field breaks old writers, and a writer that starts omitting one breaks old
156/// readers.
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub enum FieldPresence {
159    /// Always written, and required to decode.
160    Required,
161    /// Always written; an absent field still decodes, because the declaration
162    /// carries `#[serde(default)]` or is an `Option`.
163    Defaulted,
164    /// May be omitted when writing (`skip_serializing_if`), but is required to
165    /// decode.
166    Omissible,
167    /// May be omitted when writing and is not required to decode.
168    Optional,
169}
170
171impl FieldPresence {
172    /// Build the presence from the two independent serde facts.
173    #[must_use]
174    pub const fn new(defaulted: bool, omissible: bool) -> Self {
175        match (defaulted, omissible) {
176            (false, false) => Self::Required,
177            (true, false) => Self::Defaulted,
178            (false, true) => Self::Omissible,
179            (true, true) => Self::Optional,
180        }
181    }
182
183    /// Whether the wire form admits the field being absent.
184    #[must_use]
185    pub const fn admits_absence(self) -> bool {
186        matches!(self, Self::Defaulted | Self::Optional)
187    }
188
189    const fn token(self) -> &'static str {
190        match self {
191            Self::Required => "required",
192            Self::Defaulted => "defaulted",
193            Self::Omissible => "omissible",
194            Self::Optional => "optional",
195        }
196    }
197}
198
199/// How a sum type carries the variant it is.
200#[derive(Clone, Debug, Eq, PartialEq)]
201pub enum EnumRepresentation {
202    /// serde's default: a unit variant is its own name, and any other variant
203    /// is a one-key map from the variant name to its body.
204    ExternallyTagged,
205    /// `#[serde(tag = "…")]`: the variant name is a field of the body map.
206    InternallyTagged { tag: String },
207    /// `#[serde(untagged)]`: the body alone, with no discriminator.
208    Untagged,
209}
210
211/// One variant of a sum type.
212#[derive(Clone, Debug, Eq, PartialEq)]
213pub struct WireVariant {
214    /// The serialized name, after `rename` and `rename_all`.
215    pub name: String,
216    pub body: VariantBody,
217}
218
219impl WireVariant {
220    /// Declare one variant.
221    #[must_use]
222    pub fn new(name: impl Into<String>, body: VariantBody) -> Self {
223        Self {
224            name: name.into(),
225            body,
226        }
227    }
228}
229
230/// The payload one variant carries.
231#[derive(Clone, Debug, Eq, PartialEq)]
232pub enum VariantBody {
233    Unit,
234    /// A `#[serde(other)]` unit variant: the decode fallback for a variant name
235    /// this build does not know. It is wire-relevant on its own, because
236    /// removing it turns a forward-compatible decode into a hard failure.
237    Other,
238    Newtype(Box<WireSchema>),
239    Tuple(Vec<WireSchema>),
240    Struct(Vec<WireField>),
241}
242
243impl VariantBody {
244    /// A newtype variant body.
245    #[must_use]
246    pub fn newtype(inner: WireSchema) -> Self {
247        Self::Newtype(Box::new(inner))
248    }
249
250    /// A struct variant body, normalized into field-name order.
251    #[must_use]
252    pub fn structure(fields: impl IntoIterator<Item = WireField>) -> Self {
253        Self::Struct(sorted_fields(fields))
254    }
255}
256
257fn sorted_fields(fields: impl IntoIterator<Item = WireField>) -> Vec<WireField> {
258    let mut fields = fields.into_iter().collect::<Vec<_>>();
259    fields.sort_by(|left, right| left.name.cmp(&right.name));
260    fields
261}
262
263impl WireSchema {
264    /// An optional value.
265    #[must_use]
266    pub fn option(inner: WireSchema) -> Self {
267        Self::Option(Box::new(inner))
268    }
269
270    /// A homogeneous sequence.
271    #[must_use]
272    pub fn seq(item: WireSchema) -> Self {
273        Self::Seq(Box::new(item))
274    }
275
276    /// A map.
277    #[must_use]
278    pub fn map(key: WireSchema, value: WireSchema) -> Self {
279        Self::Map {
280            key: Box::new(key),
281            value: Box::new(value),
282        }
283    }
284
285    /// A fixed-length homogeneous sequence.
286    #[must_use]
287    pub fn array(element: WireSchema, length: usize) -> Self {
288        Self::Array {
289            element: Box::new(element),
290            length,
291        }
292    }
293
294    /// A newtype struct, which serde writes transparently.
295    #[must_use]
296    pub fn newtype(inner: WireSchema) -> Self {
297        Self::Newtype(Box::new(inner))
298    }
299
300    /// A struct, normalized into field-name order.
301    #[must_use]
302    pub fn structure(fields: impl IntoIterator<Item = WireField>) -> Self {
303        Self::Struct {
304            fields: sorted_fields(fields),
305        }
306    }
307
308    /// A sum type, normalized into variant-name order wherever that order is
309    /// not itself a wire fact.
310    ///
311    /// A tagged enum is resolved by looking its tag up, so reordering its
312    /// variants changes nothing and the canonical form sorts them. An untagged
313    /// enum is resolved by trying its variants in **declaration order** and
314    /// keeping the first that decodes, so for that one representation the order
315    /// is part of the contract and is preserved exactly.
316    #[must_use]
317    pub fn enumeration(
318        representation: EnumRepresentation,
319        variants: impl IntoIterator<Item = WireVariant>,
320    ) -> Self {
321        let mut variants = variants.into_iter().collect::<Vec<_>>();
322        if !matches!(representation, EnumRepresentation::Untagged) {
323            variants.sort_by(|left, right| left.name.cmp(&right.name));
324        }
325        Self::Enum {
326            representation,
327            variants,
328        }
329    }
330
331    /// The declared wire form of a hand-written `Serialize`.
332    #[must_use]
333    pub fn opaque(name: impl Into<String>, wire: WireSchema) -> Self {
334        Self::Opaque {
335            name: name.into(),
336            wire: Box::new(wire),
337        }
338    }
339
340    /// The shape with every transparent wrapper removed.
341    ///
342    /// `Opaque` names an implementation and `Newtype` is written with no
343    /// wrapping at all, so neither changes what a decoder sees. Structural
344    /// questions - "is this a map?" - are asked of this.
345    #[must_use]
346    pub fn resolved(&self) -> &WireSchema {
347        match self {
348            Self::Opaque { wire, .. } => wire.resolved(),
349            Self::Newtype(inner) => inner.resolved(),
350            other => other,
351        }
352    }
353
354    /// The canonical rendering: sorted keys, no whitespace, one shape per
355    /// declaration.
356    ///
357    /// Byte equality of two renderings is exactly structural equality of the
358    /// two schemas, which is what makes a stored baseline comparable with a
359    /// plain string compare.
360    #[must_use]
361    pub fn canonical_json(&self) -> String {
362        let mut out = String::new();
363        self.render(&mut out);
364        out
365    }
366
367    pub(crate) fn render(&self, out: &mut String) {
368        match self {
369            Self::Bool => out.push_str(r#"{"kind":"bool"}"#),
370            Self::U8 => out.push_str(r#"{"kind":"u8"}"#),
371            Self::U16 => out.push_str(r#"{"kind":"u16"}"#),
372            Self::U32 => out.push_str(r#"{"kind":"u32"}"#),
373            Self::U64 => out.push_str(r#"{"kind":"u64"}"#),
374            Self::I8 => out.push_str(r#"{"kind":"i8"}"#),
375            Self::I16 => out.push_str(r#"{"kind":"i16"}"#),
376            Self::I32 => out.push_str(r#"{"kind":"i32"}"#),
377            Self::I64 => out.push_str(r#"{"kind":"i64"}"#),
378            Self::F32 => out.push_str(r#"{"kind":"f32"}"#),
379            Self::F64 => out.push_str(r#"{"kind":"f64"}"#),
380            Self::String => out.push_str(r#"{"kind":"string"}"#),
381            Self::Bytes => out.push_str(r#"{"kind":"bytes"}"#),
382            Self::Unit => out.push_str(r#"{"kind":"unit"}"#),
383            Self::Freeform => out.push_str(r#"{"kind":"freeform"}"#),
384            Self::Option(inner) => {
385                out.push_str(r#"{"kind":"option","value":"#);
386                inner.render(out);
387                out.push('}');
388            }
389            Self::Seq(item) => {
390                out.push_str(r#"{"item":"#);
391                item.render(out);
392                out.push_str(r#","kind":"seq"}"#);
393            }
394            Self::Map { key, value } => {
395                out.push_str(r#"{"key":"#);
396                key.render(out);
397                out.push_str(r#","kind":"map","value":"#);
398                value.render(out);
399                out.push('}');
400            }
401            Self::Tuple(items) => {
402                out.push_str(r#"{"items":"#);
403                render_list(items, out, WireSchema::render);
404                out.push_str(r#","kind":"tuple"}"#);
405            }
406            Self::Array { element, length } => {
407                out.push_str(r#"{"element":"#);
408                element.render(out);
409                out.push_str(r#","kind":"array","length":"#);
410                out.push_str(&length.to_string());
411                out.push('}');
412            }
413            Self::Struct { fields } => {
414                out.push_str(r#"{"fields":"#);
415                render_list(fields, out, WireField::render);
416                out.push_str(r#","kind":"struct"}"#);
417            }
418            Self::Newtype(inner) => {
419                out.push_str(r#"{"inner":"#);
420                inner.render(out);
421                out.push_str(r#","kind":"newtype"}"#);
422            }
423            Self::Enum {
424                representation,
425                variants,
426            } => {
427                out.push_str(r#"{"kind":"enum","representation":"#);
428                representation.render(out);
429                out.push_str(r#","variants":"#);
430                render_list(variants, out, WireVariant::render);
431                out.push('}');
432            }
433            Self::Opaque { name, wire } => {
434                out.push_str(r#"{"kind":"opaque","name":"#);
435                render_string(name, out);
436                out.push_str(r#","wire":"#);
437                wire.render(out);
438                out.push('}');
439            }
440        }
441    }
442}
443
444impl WireField {
445    fn render(&self, out: &mut String) {
446        out.push_str(r#"{"name":"#);
447        render_string(&self.name, out);
448        out.push_str(r#","presence":""#);
449        out.push_str(self.presence.token());
450        out.push_str(r#"","schema":"#);
451        self.schema.render(out);
452        out.push('}');
453    }
454}
455
456impl EnumRepresentation {
457    fn render(&self, out: &mut String) {
458        match self {
459            Self::ExternallyTagged => out.push_str(r#"{"style":"external"}"#),
460            Self::InternallyTagged { tag } => {
461                out.push_str(r#"{"style":"internal","tag":"#);
462                render_string(tag, out);
463                out.push('}');
464            }
465            Self::Untagged => out.push_str(r#"{"style":"untagged"}"#),
466        }
467    }
468}
469
470impl WireVariant {
471    fn render(&self, out: &mut String) {
472        out.push_str(r#"{"body":"#);
473        self.body.render(out);
474        out.push_str(r#","name":"#);
475        render_string(&self.name, out);
476        out.push('}');
477    }
478}
479
480impl VariantBody {
481    fn render(&self, out: &mut String) {
482        match self {
483            Self::Unit => out.push_str(r#"{"kind":"unit"}"#),
484            Self::Other => out.push_str(r#"{"kind":"other"}"#),
485            Self::Newtype(inner) => {
486                out.push_str(r#"{"inner":"#);
487                inner.render(out);
488                out.push_str(r#","kind":"newtype"}"#);
489            }
490            Self::Tuple(items) => {
491                out.push_str(r#"{"items":"#);
492                render_list(items, out, WireSchema::render);
493                out.push_str(r#","kind":"tuple"}"#);
494            }
495            Self::Struct(fields) => {
496                out.push_str(r#"{"fields":"#);
497                render_list(fields, out, WireField::render);
498                out.push_str(r#","kind":"struct"}"#);
499            }
500        }
501    }
502}
503
504pub(crate) fn render_list<T>(items: &[T], out: &mut String, render: impl Fn(&T, &mut String)) {
505    out.push('[');
506    for (index, item) in items.iter().enumerate() {
507        if index > 0 {
508            out.push(',');
509        }
510        render(item, out);
511    }
512    out.push(']');
513}
514
515/// One JSON string literal, escaped exactly as a JSON writer would.
516pub(crate) fn render_string(value: &str, out: &mut String) {
517    out.push('"');
518    for character in value.chars() {
519        match character {
520            '"' => out.push_str("\\\""),
521            '\\' => out.push_str("\\\\"),
522            '\n' => out.push_str("\\n"),
523            '\r' => out.push_str("\\r"),
524            '\t' => out.push_str("\\t"),
525            control if control <= '\u{1f}' => {
526                out.push_str(&format!("\\u{:04x}", control as u32));
527            }
528            other => out.push(other),
529        }
530    }
531    out.push('"');
532}
533
534/// A type whose serialized wire shape is declared.
535///
536/// Implement it with `#[derive(phoxal_macros::DescribeWire)]` wherever the
537/// declaration alone decides the shape, and by hand beside a type's custom
538/// `Serialize` where it does not. A hand-written implementation states a fact
539/// the compiler cannot check, so it belongs next to the serializer it mirrors
540/// and is pinned by a test that serializes a real value and asserts the
541/// declared schema names the shape that came out.
542pub trait DescribeWire {
543    /// The shape a value of this type puts on the wire.
544    fn wire_schema() -> WireSchema;
545}
546
547macro_rules! primitive_wire_schema {
548    ($($ty:ty => $schema:expr),* $(,)?) => {
549        $(
550            impl DescribeWire for $ty {
551                fn wire_schema() -> WireSchema {
552                    $schema
553                }
554            }
555        )*
556    };
557}
558
559primitive_wire_schema! {
560    bool => WireSchema::Bool,
561    u8 => WireSchema::U8,
562    u16 => WireSchema::U16,
563    u32 => WireSchema::U32,
564    u64 => WireSchema::U64,
565    // serde writes both pointer-sized integers at their widest form on every
566    // target, so the wire shape does not vary with the host.
567    usize => WireSchema::U64,
568    i8 => WireSchema::I8,
569    i16 => WireSchema::I16,
570    i32 => WireSchema::I32,
571    i64 => WireSchema::I64,
572    isize => WireSchema::I64,
573    f32 => WireSchema::F32,
574    f64 => WireSchema::F64,
575    String => WireSchema::String,
576    str => WireSchema::String,
577    () => WireSchema::Unit,
578    NonZeroU32 => WireSchema::U32,
579    NonZeroU64 => WireSchema::U64,
580}
581
582impl<T: DescribeWire + ?Sized> DescribeWire for &T {
583    fn wire_schema() -> WireSchema {
584        T::wire_schema()
585    }
586}
587
588impl<T: DescribeWire + ?Sized> DescribeWire for Box<T> {
589    fn wire_schema() -> WireSchema {
590        T::wire_schema()
591    }
592}
593
594impl<T: DescribeWire> DescribeWire for Option<T> {
595    fn wire_schema() -> WireSchema {
596        WireSchema::option(T::wire_schema())
597    }
598}
599
600impl<T: DescribeWire> DescribeWire for Vec<T> {
601    fn wire_schema() -> WireSchema {
602        WireSchema::seq(T::wire_schema())
603    }
604}
605
606impl<T: DescribeWire> DescribeWire for [T] {
607    fn wire_schema() -> WireSchema {
608        WireSchema::seq(T::wire_schema())
609    }
610}
611
612impl<T: DescribeWire, const N: usize> DescribeWire for [T; N] {
613    fn wire_schema() -> WireSchema {
614        WireSchema::array(T::wire_schema(), N)
615    }
616}
617
618impl<T: DescribeWire> DescribeWire for BTreeSet<T> {
619    fn wire_schema() -> WireSchema {
620        WireSchema::seq(T::wire_schema())
621    }
622}
623
624impl<K: DescribeWire, V: DescribeWire> DescribeWire for BTreeMap<K, V> {
625    fn wire_schema() -> WireSchema {
626        WireSchema::map(K::wire_schema(), V::wire_schema())
627    }
628}
629
630macro_rules! tuple_wire_schema {
631    ($($name:ident),+) => {
632        impl<$($name: DescribeWire),+> DescribeWire for ($($name,)+) {
633            fn wire_schema() -> WireSchema {
634                WireSchema::Tuple(::std::vec![$($name::wire_schema()),+])
635            }
636        }
637    };
638}
639
640tuple_wire_schema!(A);
641tuple_wire_schema!(A, B);
642tuple_wire_schema!(A, B, C);
643tuple_wire_schema!(A, B, C, D);
644tuple_wire_schema!(A, B, C, D, E);
645tuple_wire_schema!(A, B, C, D, E, F);
646
647impl DescribeWire for serde_json::Value {
648    /// A schema-carrying config document is genuinely open: its interior is
649    /// authored per participant and is not a framework compatibility axis.
650    fn wire_schema() -> WireSchema {
651        WireSchema::opaque("serde_json::Value", WireSchema::Freeform)
652    }
653}
654
655/// A concrete value that does not have the shape its type declared.
656#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
657#[error("{path}: expected {expected}, found {found}")]
658pub struct WireMismatch {
659    /// Where in the document the disagreement is, as a JSON-ish path.
660    pub path: String,
661    pub expected: String,
662    pub found: String,
663}
664
665impl WireMismatch {
666    fn new(path: &str, expected: impl Into<String>, found: impl Into<String>) -> Self {
667        Self {
668            path: if path.is_empty() {
669                String::from("$")
670            } else {
671                path.to_string()
672            },
673            expected: expected.into(),
674            found: found.into(),
675        }
676    }
677}
678
679impl WireSchema {
680    /// Check one serialized value against this shape.
681    ///
682    /// This is what makes a hand-written [`DescribeWire`] trustworthy: a test
683    /// serializes a real value and asserts the declared schema names the shape
684    /// that actually came out, so a declaration cannot quietly drift from the
685    /// serializer beside it.
686    ///
687    /// The value is inspected as `serde_json::Value`, which collapses two
688    /// distinctions the serde data model keeps. Integer width is checked by
689    /// range rather than by type, and a byte string arrives as an array of
690    /// byte-valued numbers (what `serde_json` writes for `serialize_bytes`) or
691    /// as a string, so both are accepted for [`WireSchema::Bytes`].
692    pub fn conforms(&self, value: &serde_json::Value) -> Result<(), WireMismatch> {
693        self.check(value, "")
694    }
695
696    fn check(&self, value: &serde_json::Value, path: &str) -> Result<(), WireMismatch> {
697        use serde_json::Value;
698
699        let mismatch = |expected: &str| Err(WireMismatch::new(path, expected, describe(value)));
700        match self {
701            Self::Opaque { wire, .. } => wire.check(value, path),
702            Self::Newtype(inner) => inner.check(value, path),
703            Self::Freeform => Ok(()),
704            Self::Bool => {
705                if value.is_boolean() {
706                    Ok(())
707                } else {
708                    mismatch("bool")
709                }
710            }
711            Self::U8 => check_unsigned(value, path, u64::from(u8::MAX), "u8"),
712            Self::U16 => check_unsigned(value, path, u64::from(u16::MAX), "u16"),
713            Self::U32 => check_unsigned(value, path, u64::from(u32::MAX), "u32"),
714            Self::U64 => check_unsigned(value, path, u64::MAX, "u64"),
715            Self::I8 => check_signed(value, path, i64::from(i8::MIN), i64::from(i8::MAX), "i8"),
716            Self::I16 => check_signed(value, path, i64::from(i16::MIN), i64::from(i16::MAX), "i16"),
717            Self::I32 => check_signed(value, path, i64::from(i32::MIN), i64::from(i32::MAX), "i32"),
718            Self::I64 => check_signed(value, path, i64::MIN, i64::MAX, "i64"),
719            Self::F32 | Self::F64 => {
720                if value.is_number() {
721                    Ok(())
722                } else {
723                    mismatch("a number")
724                }
725            }
726            Self::String => {
727                if value.is_string() {
728                    Ok(())
729                } else {
730                    mismatch("a string")
731                }
732            }
733            Self::Bytes => match value {
734                Value::String(_) => Ok(()),
735                Value::Array(items) => {
736                    for (index, item) in items.iter().enumerate() {
737                        check_unsigned(
738                            item,
739                            &format!("{path}[{index}]"),
740                            u64::from(u8::MAX),
741                            "a byte",
742                        )?;
743                    }
744                    Ok(())
745                }
746                _ => mismatch("a byte string"),
747            },
748            Self::Unit => {
749                if value.is_null() {
750                    Ok(())
751                } else {
752                    mismatch("null")
753                }
754            }
755            Self::Option(inner) => {
756                if value.is_null() {
757                    Ok(())
758                } else {
759                    inner.check(value, path)
760                }
761            }
762            Self::Seq(item) => match value {
763                Value::Array(items) => {
764                    for (index, element) in items.iter().enumerate() {
765                        item.check(element, &format!("{path}[{index}]"))?;
766                    }
767                    Ok(())
768                }
769                _ => mismatch("a sequence"),
770            },
771            Self::Array { element, length } => match value {
772                Value::Array(items) if items.len() == *length => {
773                    for (index, item) in items.iter().enumerate() {
774                        element.check(item, &format!("{path}[{index}]"))?;
775                    }
776                    Ok(())
777                }
778                _ => mismatch(&format!("a sequence of exactly {length}")),
779            },
780            Self::Tuple(items) => match value {
781                Value::Array(elements) if elements.len() == items.len() => {
782                    for (index, (schema, element)) in items.iter().zip(elements).enumerate() {
783                        schema.check(element, &format!("{path}[{index}]"))?;
784                    }
785                    Ok(())
786                }
787                _ => mismatch(&format!("a tuple of {}", items.len())),
788            },
789            Self::Map { key, value: item } => match value {
790                Value::Object(entries) => {
791                    for (name, entry) in entries {
792                        key.check_key(name, path)?;
793                        item.check(entry, &format!("{path}.{name}"))?;
794                    }
795                    Ok(())
796                }
797                _ => mismatch("a map"),
798            },
799            Self::Struct { fields } => match value {
800                Value::Object(entries) => check_fields(fields, entries, path),
801                _ => mismatch("a map"),
802            },
803            Self::Enum {
804                representation,
805                variants,
806            } => representation.check(variants, value, path),
807        }
808    }
809
810    /// A map key, which every Phoxal codec writes as text even when the key
811    /// type is not a string.
812    fn check_key(&self, key: &str, path: &str) -> Result<(), WireMismatch> {
813        let path = format!("{path}.{key}(key)");
814        match self.resolved() {
815            Self::U8 | Self::U16 | Self::U32 | Self::U64 => match key.parse::<u64>() {
816                Ok(parsed) => self.check(&serde_json::Value::from(parsed), &path),
817                Err(_) => Err(WireMismatch::new(&path, "an unsigned key", "text")),
818            },
819            Self::I8 | Self::I16 | Self::I32 | Self::I64 => match key.parse::<i64>() {
820                Ok(parsed) => self.check(&serde_json::Value::from(parsed), &path),
821                Err(_) => Err(WireMismatch::new(&path, "a signed key", "text")),
822            },
823            _ => self.check(&serde_json::Value::String(key.to_string()), &path),
824        }
825    }
826}
827
828fn check_fields(
829    fields: &[WireField],
830    entries: &serde_json::Map<String, serde_json::Value>,
831    path: &str,
832) -> Result<(), WireMismatch> {
833    for field in fields {
834        match entries.get(&field.name) {
835            Some(value) => field
836                .schema
837                .check(value, &format!("{path}.{}", field.name))?,
838            None if field.presence.admits_absence() => {}
839            None => {
840                return Err(WireMismatch::new(
841                    &format!("{path}.{}", field.name),
842                    "the declared field",
843                    "an absent field",
844                ));
845            }
846        }
847    }
848    for name in entries.keys() {
849        if !fields.iter().any(|field| field.name == *name) {
850            return Err(WireMismatch::new(
851                &format!("{path}.{name}"),
852                "no such field in the declared schema",
853                "a serialized field",
854            ));
855        }
856    }
857    Ok(())
858}
859
860impl EnumRepresentation {
861    fn check(
862        &self,
863        variants: &[WireVariant],
864        value: &serde_json::Value,
865        path: &str,
866    ) -> Result<(), WireMismatch> {
867        use serde_json::Value;
868
869        match self {
870            Self::ExternallyTagged => match value {
871                Value::String(name) => match find(variants, name) {
872                    Some(variant)
873                        if matches!(variant.body, VariantBody::Unit | VariantBody::Other) =>
874                    {
875                        Ok(())
876                    }
877                    Some(_) => Err(WireMismatch::new(path, "a unit variant name", "a string")),
878                    None => Err(WireMismatch::new(path, "a declared variant", "a string")),
879                },
880                Value::Object(entries) if entries.len() == 1 => {
881                    let Some((name, body)) = entries.iter().next() else {
882                        return Err(WireMismatch::new(path, "a one-key map", "an empty map"));
883                    };
884                    let Some(variant) = find(variants, name) else {
885                        return Err(WireMismatch::new(
886                            &format!("{path}.{name}"),
887                            "a declared variant",
888                            "an unknown variant",
889                        ));
890                    };
891                    variant.body.check(body, &format!("{path}.{name}"))
892                }
893                _ => Err(WireMismatch::new(
894                    path,
895                    "an externally tagged enum",
896                    describe(value),
897                )),
898            },
899            Self::InternallyTagged { tag } => {
900                let Value::Object(entries) = value else {
901                    return Err(WireMismatch::new(path, "a map", describe(value)));
902                };
903                let Some(Value::String(name)) = entries.get(tag) else {
904                    return Err(WireMismatch::new(
905                        &format!("{path}.{tag}"),
906                        "the variant tag",
907                        "an absent or non-text tag",
908                    ));
909                };
910                let Some(variant) = find(variants, name) else {
911                    return Err(WireMismatch::new(
912                        &format!("{path}.{tag}"),
913                        "a declared variant",
914                        "an unknown variant",
915                    ));
916                };
917                let mut body = entries.clone();
918                body.remove(tag);
919                match &variant.body {
920                    VariantBody::Unit | VariantBody::Other => {
921                        if body.is_empty() {
922                            Ok(())
923                        } else {
924                            Err(WireMismatch::new(path, "no further fields", "extra fields"))
925                        }
926                    }
927                    VariantBody::Struct(fields) => check_fields(fields, &body, path),
928                    // serde merges a newtype variant's map into the tagged
929                    // object, so the inner shape has to be a map itself.
930                    VariantBody::Newtype(inner) => match inner.resolved() {
931                        WireSchema::Struct { fields } => check_fields(fields, &body, path),
932                        _ => Err(WireMismatch::new(
933                            path,
934                            "a newtype variant over a map",
935                            "a non-map inner shape",
936                        )),
937                    },
938                    VariantBody::Tuple(_) => Err(WireMismatch::new(
939                        path,
940                        "no tuple variant under an internal tag",
941                        "a tuple variant",
942                    )),
943                }
944            }
945            Self::Untagged => {
946                for variant in variants {
947                    if variant.body.check(value, path).is_ok() {
948                        return Ok(());
949                    }
950                }
951                Err(WireMismatch::new(
952                    path,
953                    "any declared untagged variant",
954                    describe(value),
955                ))
956            }
957        }
958    }
959}
960
961fn find<'a>(variants: &'a [WireVariant], name: &str) -> Option<&'a WireVariant> {
962    variants.iter().find(|variant| variant.name == name)
963}
964
965impl VariantBody {
966    fn check(&self, value: &serde_json::Value, path: &str) -> Result<(), WireMismatch> {
967        match self {
968            Self::Unit | Self::Other => WireSchema::Unit.check(value, path),
969            Self::Newtype(inner) => inner.check(value, path),
970            Self::Tuple(items) => WireSchema::Tuple(items.clone()).check(value, path),
971            Self::Struct(fields) => match value {
972                serde_json::Value::Object(entries) => check_fields(fields, entries, path),
973                _ => Err(WireMismatch::new(path, "a map", describe(value))),
974            },
975        }
976    }
977}
978
979fn check_unsigned(
980    value: &serde_json::Value,
981    path: &str,
982    max: u64,
983    expected: &str,
984) -> Result<(), WireMismatch> {
985    match value.as_u64() {
986        Some(parsed) if parsed <= max => Ok(()),
987        _ => Err(WireMismatch::new(path, expected, describe(value))),
988    }
989}
990
991fn check_signed(
992    value: &serde_json::Value,
993    path: &str,
994    min: i64,
995    max: i64,
996    expected: &str,
997) -> Result<(), WireMismatch> {
998    match value.as_i64() {
999        Some(parsed) if (min..=max).contains(&parsed) => Ok(()),
1000        _ => Err(WireMismatch::new(path, expected, describe(value))),
1001    }
1002}
1003
1004fn describe(value: &serde_json::Value) -> String {
1005    match value {
1006        serde_json::Value::Null => String::from("null"),
1007        serde_json::Value::Bool(_) => String::from("a bool"),
1008        serde_json::Value::Number(number) => format!("the number {number}"),
1009        serde_json::Value::String(_) => String::from("a string"),
1010        serde_json::Value::Array(items) => format!("a sequence of {}", items.len()),
1011        serde_json::Value::Object(_) => String::from("a map"),
1012    }
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017    use super::*;
1018
1019    fn field(name: &str, schema: WireSchema) -> WireField {
1020        WireField::required(name, schema)
1021    }
1022
1023    /// The canonical rendering is a function of the shape alone, so two
1024    /// declarations that differ only in the order their fields and variants
1025    /// were written render the same bytes. This is what lets a checker compare
1026    /// a stored baseline with a plain string equality.
1027    #[test]
1028    fn authoring_order_does_not_change_the_canonical_bytes() {
1029        let one = WireSchema::structure([
1030            field("alpha", WireSchema::U32),
1031            field("beta", WireSchema::String),
1032            field("gamma", WireSchema::Bool),
1033        ]);
1034        let other = WireSchema::structure([
1035            field("gamma", WireSchema::Bool),
1036            field("alpha", WireSchema::U32),
1037            field("beta", WireSchema::String),
1038        ]);
1039        assert_eq!(one.canonical_json(), other.canonical_json());
1040        assert_eq!(one, other);
1041
1042        let first = WireSchema::enumeration(
1043            EnumRepresentation::ExternallyTagged,
1044            [
1045                WireVariant::new("a", VariantBody::Unit),
1046                WireVariant::new("b", VariantBody::newtype(WireSchema::U8)),
1047            ],
1048        );
1049        let second = WireSchema::enumeration(
1050            EnumRepresentation::ExternallyTagged,
1051            [
1052                WireVariant::new("b", VariantBody::newtype(WireSchema::U8)),
1053                WireVariant::new("a", VariantBody::Unit),
1054            ],
1055        );
1056        assert_eq!(first.canonical_json(), second.canonical_json());
1057    }
1058
1059    /// Element order in a tuple or an array *is* the wire shape, so it is one
1060    /// place normalization must not reach.
1061    #[test]
1062    fn positional_element_order_is_preserved() {
1063        let one = WireSchema::Tuple(vec![WireSchema::U8, WireSchema::String]);
1064        let other = WireSchema::Tuple(vec![WireSchema::String, WireSchema::U8]);
1065        assert_ne!(one.canonical_json(), other.canonical_json());
1066    }
1067
1068    /// An untagged enum decodes by keeping the first variant that accepts the
1069    /// value, so its declaration order decides what an ambiguous value becomes
1070    /// and must survive normalization. A tagged one is looked up by name and
1071    /// does not.
1072    #[test]
1073    fn untagged_variant_order_is_the_one_variant_order_that_survives() {
1074        let declared = [
1075            WireVariant::new("Zulu", VariantBody::newtype(WireSchema::I64)),
1076            WireVariant::new("Alpha", VariantBody::newtype(WireSchema::F64)),
1077        ];
1078        let untagged = WireSchema::enumeration(EnumRepresentation::Untagged, declared.clone());
1079        let WireSchema::Enum { variants, .. } = &untagged else {
1080            panic!("an enum");
1081        };
1082        assert_eq!(variants[0].name, "Zulu");
1083
1084        let tagged = WireSchema::enumeration(EnumRepresentation::ExternallyTagged, declared);
1085        let WireSchema::Enum { variants, .. } = &tagged else {
1086            panic!("an enum");
1087        };
1088        assert_eq!(variants[0].name, "Alpha");
1089    }
1090
1091    /// Every rendering is one whitespace-free JSON object whose keys ascend, so
1092    /// a reviewer reading a diff sees a stable document and not a formatting
1093    /// artifact.
1094    #[test]
1095    fn every_variant_renders_as_canonical_json() {
1096        let every = [
1097            WireSchema::Bool,
1098            WireSchema::U8,
1099            WireSchema::U16,
1100            WireSchema::U32,
1101            WireSchema::U64,
1102            WireSchema::I8,
1103            WireSchema::I16,
1104            WireSchema::I32,
1105            WireSchema::I64,
1106            WireSchema::F32,
1107            WireSchema::F64,
1108            WireSchema::String,
1109            WireSchema::Bytes,
1110            WireSchema::Unit,
1111            WireSchema::Freeform,
1112            WireSchema::option(WireSchema::U8),
1113            WireSchema::seq(WireSchema::U8),
1114            WireSchema::map(WireSchema::String, WireSchema::U8),
1115            WireSchema::Tuple(vec![WireSchema::U8, WireSchema::Bool]),
1116            WireSchema::array(WireSchema::U8, 4),
1117            WireSchema::structure([field("only", WireSchema::U8)]),
1118            WireSchema::newtype(WireSchema::String),
1119            WireSchema::enumeration(
1120                EnumRepresentation::InternallyTagged {
1121                    tag: String::from("schema"),
1122                },
1123                [WireVariant::new(
1124                    "v0",
1125                    VariantBody::structure([field("value", WireSchema::U8)]),
1126                )],
1127            ),
1128            WireSchema::enumeration(
1129                EnumRepresentation::Untagged,
1130                [
1131                    WireVariant::new("Text", VariantBody::newtype(WireSchema::String)),
1132                    WireVariant::new("Count", VariantBody::Other),
1133                    WireVariant::new("Pair", VariantBody::Tuple(vec![WireSchema::U8])),
1134                ],
1135            ),
1136            WireSchema::opaque("Digest", WireSchema::String),
1137        ];
1138        for schema in every {
1139            let rendered = schema.canonical_json();
1140            assert!(
1141                !rendered.contains(' ') && rendered.starts_with('{') && rendered.ends_with('}'),
1142                "{rendered}"
1143            );
1144            let parsed = serde_json::from_str::<serde_json::Value>(&rendered)
1145                .expect("the canonical rendering is JSON");
1146            assert!(parsed.is_object(), "{rendered}");
1147            // A second render of the same shape is the same bytes.
1148            assert_eq!(schema.canonical_json(), rendered);
1149        }
1150    }
1151
1152    #[test]
1153    fn nested_composition_renders_deterministically() {
1154        let schema = WireSchema::structure([
1155            field(
1156                "rows",
1157                WireSchema::seq(WireSchema::structure([
1158                    field(
1159                        "id",
1160                        WireSchema::opaque("ParticipantId", WireSchema::String),
1161                    ),
1162                    WireField::new(
1163                        "detail",
1164                        WireSchema::option(WireSchema::String),
1165                        FieldPresence::Optional,
1166                    ),
1167                ])),
1168            ),
1169            field(
1170                "index",
1171                WireSchema::map(WireSchema::String, WireSchema::seq(WireSchema::U32)),
1172            ),
1173        ]);
1174        assert_eq!(
1175            schema.canonical_json(),
1176            concat!(
1177                r#"{"fields":["#,
1178                r#"{"name":"index","presence":"required","schema":"#,
1179                r#"{"key":{"kind":"string"},"kind":"map","value":{"item":{"kind":"u32"},"kind":"seq"}}},"#,
1180                r#"{"name":"rows","presence":"required","schema":{"item":{"fields":["#,
1181                r#"{"name":"detail","presence":"optional","schema":{"kind":"option","value":{"kind":"string"}}},"#,
1182                r#"{"name":"id","presence":"required","schema":{"kind":"opaque","name":"ParticipantId","wire":{"kind":"string"}}}"#,
1183                r#"],"kind":"struct"},"kind":"seq"}}"#,
1184                r#"],"kind":"struct"}"#,
1185            )
1186        );
1187    }
1188
1189    #[test]
1190    fn a_name_with_json_metacharacters_is_escaped() {
1191        let schema = WireSchema::opaque("a\"b\\c\nd", WireSchema::Unit);
1192        let rendered = schema.canonical_json();
1193        assert!(rendered.contains(r#""a\"b\\c\nd""#), "{rendered}");
1194        serde_json::from_str::<serde_json::Value>(&rendered).expect("still valid JSON");
1195    }
1196
1197    #[test]
1198    fn the_standard_impls_describe_the_shapes_serde_writes() {
1199        assert_eq!(
1200            <Option<u8>>::wire_schema(),
1201            WireSchema::option(WireSchema::U8)
1202        );
1203        assert_eq!(
1204            <Vec<String>>::wire_schema(),
1205            WireSchema::seq(WireSchema::String)
1206        );
1207        assert_eq!(
1208            <BTreeMap<String, u64>>::wire_schema(),
1209            WireSchema::map(WireSchema::String, WireSchema::U64)
1210        );
1211        assert_eq!(
1212            <[u8; 3]>::wire_schema(),
1213            WireSchema::array(WireSchema::U8, 3)
1214        );
1215        assert_eq!(
1216            <(u8, bool)>::wire_schema(),
1217            WireSchema::Tuple(vec![WireSchema::U8, WireSchema::Bool])
1218        );
1219        assert_eq!(<&str>::wire_schema(), WireSchema::String);
1220        assert_eq!(<()>::wire_schema(), WireSchema::Unit);
1221        assert_eq!(<NonZeroU64>::wire_schema(), WireSchema::U64);
1222        assert_eq!(
1223            <serde_json::Value>::wire_schema(),
1224            WireSchema::opaque("serde_json::Value", WireSchema::Freeform)
1225        );
1226        // Pointer-sized integers are written at their widest form regardless of
1227        // the host, so the declared shape cannot vary with the target.
1228        assert_eq!(<usize>::wire_schema(), WireSchema::U64);
1229    }
1230
1231    #[test]
1232    fn conformance_accepts_the_shape_and_names_the_first_disagreement() {
1233        let schema = WireSchema::structure([
1234            field("count", WireSchema::U8),
1235            WireField::new(
1236                "label",
1237                WireSchema::option(WireSchema::String),
1238                FieldPresence::Optional,
1239            ),
1240        ]);
1241        assert_eq!(
1242            schema.conforms(&serde_json::json!({"count": 7, "label": "ok"})),
1243            Ok(())
1244        );
1245        // An omissible field may be absent.
1246        assert_eq!(schema.conforms(&serde_json::json!({"count": 7})), Ok(()));
1247
1248        let missing = schema
1249            .conforms(&serde_json::json!({"label": null}))
1250            .expect_err("a required field cannot be absent");
1251        assert_eq!(missing.path, ".count");
1252
1253        let extra = schema
1254            .conforms(&serde_json::json!({"count": 1, "label": null, "surprise": 2}))
1255            .expect_err("an undeclared field is a disagreement");
1256        assert_eq!(extra.path, ".surprise");
1257
1258        let too_wide = schema
1259            .conforms(&serde_json::json!({"count": 300}))
1260            .expect_err("an out-of-range integer is not a u8");
1261        assert_eq!(too_wide.expected, "u8");
1262    }
1263
1264    #[test]
1265    fn conformance_reads_each_enum_representation_the_way_serde_writes_it() {
1266        let external = WireSchema::enumeration(
1267            EnumRepresentation::ExternallyTagged,
1268            [
1269                WireVariant::new("stop", VariantBody::Unit),
1270                WireVariant::new(
1271                    "go",
1272                    VariantBody::structure([field("speed", WireSchema::F32)]),
1273                ),
1274            ],
1275        );
1276        assert_eq!(external.conforms(&serde_json::json!("stop")), Ok(()));
1277        assert_eq!(
1278            external.conforms(&serde_json::json!({"go": {"speed": 1.5}})),
1279            Ok(())
1280        );
1281        assert!(external.conforms(&serde_json::json!("fly")).is_err());
1282
1283        let internal = WireSchema::enumeration(
1284            EnumRepresentation::InternallyTagged {
1285                tag: String::from("schema"),
1286            },
1287            [WireVariant::new(
1288                "phoxal/example/v0",
1289                VariantBody::structure([field("value", WireSchema::U8)]),
1290            )],
1291        );
1292        assert_eq!(
1293            internal.conforms(&serde_json::json!({"schema": "phoxal/example/v0", "value": 3})),
1294            Ok(())
1295        );
1296        assert!(
1297            internal
1298                .conforms(&serde_json::json!({"schema": "phoxal/example/v1", "value": 3}))
1299                .is_err()
1300        );
1301
1302        // An internally tagged newtype variant merges the inner map into the
1303        // tagged object, exactly as serde writes it.
1304        let merged = WireSchema::enumeration(
1305            EnumRepresentation::InternallyTagged {
1306                tag: String::from("schema"),
1307            },
1308            [WireVariant::new(
1309                "phoxal/example/v0",
1310                VariantBody::newtype(WireSchema::opaque(
1311                    "Inner",
1312                    WireSchema::structure([field("value", WireSchema::U8)]),
1313                )),
1314            )],
1315        );
1316        assert_eq!(
1317            merged.conforms(&serde_json::json!({"schema": "phoxal/example/v0", "value": 3})),
1318            Ok(())
1319        );
1320
1321        let untagged = WireSchema::enumeration(
1322            EnumRepresentation::Untagged,
1323            [
1324                WireVariant::new("Bool", VariantBody::newtype(WireSchema::Bool)),
1325                WireVariant::new("Text", VariantBody::newtype(WireSchema::String)),
1326            ],
1327        );
1328        assert_eq!(untagged.conforms(&serde_json::json!(true)), Ok(()));
1329        assert_eq!(untagged.conforms(&serde_json::json!("hello")), Ok(()));
1330        assert!(untagged.conforms(&serde_json::json!(1)).is_err());
1331    }
1332
1333    #[test]
1334    fn conformance_reads_a_byte_string_in_both_renderings() {
1335        assert_eq!(
1336            WireSchema::Bytes.conforms(&serde_json::json!([1, 2, 255])),
1337            Ok(())
1338        );
1339        assert_eq!(WireSchema::Bytes.conforms(&serde_json::json!("ab")), Ok(()));
1340        assert!(
1341            WireSchema::Bytes
1342                .conforms(&serde_json::json!([256]))
1343                .is_err()
1344        );
1345    }
1346
1347    #[test]
1348    fn conformance_checks_a_maps_keys_as_well_as_its_values() {
1349        let text_keys = WireSchema::map(
1350            WireSchema::opaque("CapabilityId", WireSchema::String),
1351            WireSchema::I8,
1352        );
1353        assert_eq!(
1354            text_keys.conforms(&serde_json::json!({"wheel": -1})),
1355            Ok(())
1356        );
1357        assert!(
1358            text_keys
1359                .conforms(&serde_json::json!({"wheel": 900}))
1360                .is_err()
1361        );
1362
1363        // A numeric key is still written as text, so it is parsed back before
1364        // the declared shape decides.
1365        let numeric_keys = WireSchema::map(WireSchema::U32, WireSchema::Bool);
1366        assert_eq!(
1367            numeric_keys.conforms(&serde_json::json!({"7": true})),
1368            Ok(())
1369        );
1370        assert!(
1371            numeric_keys
1372                .conforms(&serde_json::json!({"seven": true}))
1373                .is_err()
1374        );
1375    }
1376
1377    /// A newtype wrapper and a named opaque form are both transparent to a
1378    /// decoder, so resolving through them is what structural questions ask.
1379    #[test]
1380    fn transparent_wrappers_resolve_to_the_shape_underneath() {
1381        let schema = WireSchema::opaque(
1382            "Wrapper",
1383            WireSchema::newtype(WireSchema::structure([field("value", WireSchema::U8)])),
1384        );
1385        assert!(matches!(schema.resolved(), WireSchema::Struct { .. }));
1386        assert_eq!(schema.conforms(&serde_json::json!({"value": 1})), Ok(()));
1387    }
1388}