Wado

WEP: CBOR Standard Library (core:cbor)

Context

Wado has a self-describing text format (core:json) but no binary interchange format. CBOR — Concise Binary Object Representation, RFC 8949 (STD 94, obsoletes RFC 7049) — fills that gap. It is an IETF Internet Standard whose data model is a strict superset of JSON's, it is self-describing (no schema needed), it is compact, and it defines a deterministic-encoding profile suitable for signing (COSE/CWT). The verbatim spec is vendored at wado-compiler/ref/rfc8949.txt so implementation comments can cite exact sections offline.

The serde framework was designed from the start "for JSON and CBOR": signed/unsigned and f32/f64 are separate data-model entries specifically so a CBOR encoder needs no runtime width probing, and DeserializeVariant::disc() exists for integer-tagged variants. This WEP realizes the CBOR half. Derived Serialize/Deserialize impls work unchanged — a type that serializes to JSON serializes to CBOR with no source changes.

Design goals

  1. Reuse serde. No CBOR-specific compiler knowledge; #[derive]-equivalent synthesized impls drive both formats.
  2. Bytes-primary I/O. CBOR is binary; the API is ByteList-in, ByteList-out, symmetric with the core:json redesign below.
  3. Full RFC 8949 coverage on decode (variation-tolerant: any width, definite or indefinite length), and preferred serialization on encode.
  4. A deterministic mode (to_bytes_canonical) for COSE-style signing.
  5. Security by construction: bounded recursion, no allocation sized by untrusted input.

Prior art

Library Default encode Canonical/deterministic Bytes type Dynamic value
Rust ciborium preferred — (separate crates) serde_bytes / Vec<u8> ciborium::Value (has Tag, Bytes)
Rust serde_cbor preferred to_vec + canonical flag serde_bytes serde_cbor::Value
Go fxamacker/cbor options-based CanonicalEncOptions, CTAP2, Core Deterministic native []byte cbor.RawMessage, any
Python cbor2 preferred canonical=True native bytes native types + CBORTag

The two-mode split (a fast default plus an opt-in canonical encoder) is universal. Every library distinguishes a byte string from an array of small integers — the data-model gap serde currently has. This WEP closes both.

Decision

Module and public API

core:cbor is self-describing (structs encode as maps with text-string keys), mirroring core:json. The API is bytes-only.

#![stdlib("core:cbor")]

/// Serialize to CBOR using preferred (shortest) serialization. Not guaranteed
/// to be deterministic across equal values whose maps were built in different
/// orders; use `to_bytes_canonical` when byte-exact reproducibility matters.
pub fn to_bytes<T: Serialize>(value: &T) -> Result<ByteList, SerializeError>;

/// Serialize using the Core Deterministic Encoding profile (RFC 8949 §4.2.1),
/// with the documented float caveat below.
pub fn to_bytes_canonical<T: Serialize>(value: &T) -> Result<ByteList, SerializeError>;

/// Deserialize from CBOR. `strict` (default `true`) rejects items the target
/// cannot represent; `strict = false` is only meaningful when deserializing
/// into `core:value`'s `Value`, where unrepresentable items fall to
/// `Value::Unknown` instead of erroring.
pub fn from_bytes<T: Deserialize, B: AsByteSlice>(input: B, strict: bool = true) -> Result<T, DeserializeError>;

There is no to_bytes_pretty (CBOR is binary). Diagnostic notation (RFC 8949 §8) is out of scope; if needed it becomes a separate debug helper later.

Byte types and the from_*/to_* convention

CBOR's byte string (major type 2) has no serde data-model entry today, so List<u8> serializes as an array of integers. Three prelude newtypes close the gap, mirroring Rust's [u8; N] / &[u8] / Vec<u8>:

// in core:prelude
type ByteArray = Array<u8>;       // owned, fixed length (hashes, keys)
type ByteList  = List<u8>;        // owned, growable
type ByteSlice = ArraySlice<u8>;  // borrowed view (no copy)

Letting from_* accept any of the three without copying needs a byte-slice conversion trait. Introducing it is deliberate: a byte sequence is a first-class concept that deserves its own abstraction across the ecosystem, not an incidental List<u8>. Wado has only From/TryFrom today (no Into, AsRef, or slice-view trait), so this is part of the WEP's groundwork:

pub trait AsByteSlice {
    fn as_byte_slice(&self) -> ByteSlice;
}
// impl for ByteArray, ByteList, and ByteSlice (identity) — each a zero-copy view.

from_* accepts any B: AsByteSlice.

Convention, applied to both core:cbor and core:json:

Serialize is implemented for all three (each encodes as a byte string). Deserialize is implemented for ByteList (primary) and ByteArray (sized to the decoded byte count — a fixed-length array's length is a runtime value, so there is nothing to check at compile time); ByteSlice is a borrowed view and so has no owned deserialize, exactly like Rust's &[u8]: !DeserializeOwned.

serde framework changes (groundwork)

This WEP requires the following additions to core:serde. They are format-agnostic and benefit every format.

A bytes entry in the data model

// Serializer
fn serialize_bytes(&mut self, v: ByteSlice) -> Result<(), SerializeError>;
// Deserializer
fn deserialize_bytes(&mut self) -> Result<ByteList, DeserializeError>;

core:cbor emits/reads a byte string (major type 2). core:json emits base64url without padding and reads it back (RFC 8949 §6.1 advice for CBOR→JSON).

Visitor completion

The Visitor trait currently has visit_f64 but no integer or bytes entries, forcing large CBOR integers (> 2^53) through a lossy f64. This was a design gap. Add, each with a default implementation so existing Visitor impls (which only care about JSON) need no changes:

fn visit_i64(&mut self, v: i64) -> Result<Self::Value, DeserializeError>;     // default: visit_f64(v as f64)
fn visit_u64(&mut self, v: u64) -> Result<Self::Value, DeserializeError>;     // default: visit_f64(v as f64)
fn visit_i128(&mut self, v: i128) -> Result<Self::Value, DeserializeError>;   // default: visit_f64(v as f64)
fn visit_u128(&mut self, v: u128) -> Result<Self::Value, DeserializeError>;   // default: visit_f64(v as f64)
fn visit_bytes(&mut self, v: ByteList) -> Result<Self::Value, DeserializeError>;   // default: UnexpectedType
fn visit_unknown(&mut self, raw: ByteList) -> Result<Self::Value, DeserializeError>; // default: UnexpectedType

visit_unknown is only ever called by core:cbor (in lenient mode); JSON represents every value it parses, so it never calls it.

FieldSchema::lookup keyed by bytes

The synthesized field lookup currently takes &String:

fn lookup(input: &String, start: i32, end: i32) -> Option<i32>;   // before
fn lookup(key: ByteSlice) -> Option<i32>;                          // after

A String input is wrong once deserializer input is bytes (below). The new signature takes the text-key byte range directly. The lookup is synthesized per type by the compiler, so the change is mechanical in the struct-deserialize synthesizer; hand-written Deserializer impls (core:json, core:json_nsd, core:router) pass a ByteSlice of their buffer.

Bytes-primary deserialization for core:json

serde crosses the I/O boundary, and that boundary is bytes (UTF-8), not String. The serialize side already hands back a raw byte buffer (to_bytes). The deserialize side is made symmetric:

With bytes-based input, FieldSchema::lookup(ByteSlice) is satisfied identically by JSON and CBOR — the String/bytes impedance mismatch that the original serde design papered over disappears.

core:json becomes bytes-only

pub fn to_bytes<T: Serialize>(value: &T) -> Result<ByteList, SerializeError>;
pub fn to_bytes_canonical<T: Serialize>(value: &T) -> Result<ByteList, SerializeError>;   // sorted keys, RFC 8785-style
pub fn to_bytes_pretty<T: Serialize>(value: &T) -> Result<ByteList, SerializeError>;
pub fn from_bytes<T: Deserialize, B: AsByteSlice>(input: B) -> Result<T, DeserializeError>;

The string-returning entries (to_string, to_string_pretty, from_string) are removed. core:json gains to_bytes_canonical (lexicographically sorted keys) for parity with CBOR and for canonical JSON use cases. core:json_nsd and core:router follow the same input/FieldSchema migration.

core:value replaces core:json_value

A single dynamic value type serves both formats; see below.

Encoding: serde data model → CBOR

serde call CBOR encoding
serialize_i32 / serialize_i64 major type 0 (≥ 0) or 1 (< 0), preferred shortest argument
serialize_u32 / serialize_u64 major type 0, preferred shortest
serialize_i128 / serialize_u128 major 0/1 if it fits the u64 magnitude range; otherwise bignum tag 2 (non-negative) / 3 (negative) wrapping the big-endian byte string
serialize_f32 f9/fa head, IEEE-754 binary32 (additional info 26)
serialize_f64 binary64 (additional info 27)
serialize_bool 0xf4 (false) / 0xf5 (true)
serialize_null 0xf6 (null)
serialize_char text string of one Unicode scalar
serialize_string text string, major type 3 (never escaped)
serialize_bytes byte string, major type 2
begin_seq(n) / tuple array, major type 4, definite length n
begin_map(n) / begin_struct(n) map, major type 5, definite length, text-string keys
unit variant the variant name as a text string (external tagging)
begin_variant single-pair map { name: payload }

Notes:

Decoding

The decoder dispatches on the initial byte (RFC 8949 Appendix B jump table) and is variation-tolerant (RFC 8949 §4.1): it accepts any well-formed encoding, preferred or not.

Well-formedness and validity errors (RFC 8949 §5.3, Appendix F): duplicate map keys → DuplicateField (§5.6); odd map item count, reserved additional info 28–30, a break outside an indefinite item → MalformedInput; premature end → Eof; bytes left after the top-level item → TrailingData.

Canonical encoding (to_bytes_canonical)

Implements RFC 8949 §4.2.1 Core Deterministic Encoding:

Float caveat (intentional deviation)

RFC 8949 §4.2.1 requires the shortest float that preserves the value, which can be binary16. Wado has no f16, so the canonical encoder's float ladder stops at binary32: it emits f32 where a fully-conformant encoder would emit f16.

Consequently to_bytes_canonical is byte-identical to a reference deterministic encoder for integers, map ordering, and lengths, but may differ on float-bearing values. For COSE/CWT signing over data containing floats, this is a real interoperability hazard and must be documented at the call site. Emitting f16 as raw bits (without an f16 type) in canonical mode only is a possible future refinement.

core:value: the unified dynamic value

core:value replaces core:json_value with a single format-agnostic Value used by both core:json and core:cbor. It is a general dynamic value, so it deliberately contains no CBOR-specific concepts (no Tag, no Simple): a single opaque escape hatch, Unknown, carries anything the model cannot represent.

#![stdlib("core:value")]

pub variant Value {
    Null,
    Bool(bool),
    Int(i64),
    UInt(u64),
    Int128(i128),
    UInt128(u128),
    Float(f64),                      // binary16/32/64 all widened to f64
    String(String),
    Bytes(ByteList),                 // a byte-string *value*
    List(List<Value>),
    Object(TreeMap<String, Value>),  // string keys only, insertion order preserved
    Undefined,                       // CBOR simple 23; also present in JS/MsgPack data
    Unknown(ByteList),               // raw encoded bytes of an unrepresentable item
}

Bytes versus Unknown

These are different and must not be conflated:

Tags and simple values collapse into native arms or Unknown

There is no Tag arm. Known tags are interpreted into native arms during decode (date/time 0/1 → number/string, bignum 2/3 → Int128/UInt128, base64 hints 21–23, self-described 55799), matching RFC 8949 §6.1's advice that a CBOR→JSON converter uses a tag's content and ignores the tag number. Genuinely unknown tags, unassigned simple values (0–19, 32–255 — 24–31 cannot occur), and reserved forms are either a strict-mode DeserializeError or, when strict = false, preserved as Unknown(raw_bytes). The cost is that an unknown tag's content is opaque in Value (tags that matter are handled by typed Deserialize impls instead); the benefit is that Value stays a clean, format-neutral model.

Integer decode: narrowest fit

A decoded integer goes into the smallest arm that holds it, so equal numbers have one representation and canonical re-encoding is shortest-form:

Map keys

Object has string keys only. A CBOR map with a non-string key cannot be represented and is a strict-mode DeserializeError; a lenient stringifying mode (integer key → decimal string, with the collision caveat of RFC 8949 §6.1) is possible future work, not the default.

null/undefined and typed targets

Both null and undefined deserialize to Option::None. In a non-Option target, undefined is an UnexpectedType error.

CBOR ↔ JSON via Value (RFC 8949 §6)

Round-tripping CBOR through Value and serializing it with core:json may hit arms JSON cannot express: Bytes, Undefined, Unknown, non-finite Float. The default is to error (SerializeError). A lossy conversion following RFC 8949 §6.1 substitution (bytes → base64 string, tag content used, undefined/non-finite → null) is deferred; it is additive and does not affect the fixed core:json API above.

Date/time mapping

Typed timestamps map to core:temporal:

The impls are format-agnostic and live in core:temporal itself, shared by core:cbor and core:json through a Serializer::serialize_tag hook (a no-op for tagless formats).

Module/source layout

The encoder/decoder live in lib/core/cbor.wado, with the value type in lib/core/value.wado (replacing json_value.wado). Shared low-level helpers (argument/length varint read/write, UTF-8 validation already in core:json) are reused via pub functions where practical, as core:json and core:json_nsd already share code.

Security considerations (RFC 8949 §10)

Consequences

TODO

The groundwork, the core:cbor format itself (encoder, decoder, canonical encoding), and the typed core:temporal date/time mapping are complete. The remaining item is lossy CBOR→JSON conversion.