RFC 8785 JSON Canonicalization Scheme over the closed tagged algebra
produced by AgentBlueprintProtocol.Json.
This is the package's one and only encoder: object members are sorted by
UTF-16 code unit (sort_key/1), strings use the §3.2.2.2 escape set, and
floats serialize exactly as ECMAScript §7.1.12.1 (including the Note 2
shortest round-trip rule) — digits from :erlang.float_to_binary/2 with
[:short], notation re-formatted per ECMA-262. Output is UTF-8 with no
whitespace between tokens.
Fail-closed guards on the encode path (a value can reach the encoder
without ever passing the decoder): invalid UTF-8 and lone surrogates deny
with :invalid_encoding; duplicate object names deny with
:duplicate_member; a directly-constructed tagged integer above the
I-JSON bound ±(2^53−1) denies with :integer_magnitude — decode can no
longer produce one (the integer window float-tags above-bound lexemes), but a
hand-built value still fails closed so the package never emits bytes an
ECMAScript peer would serialize differently. The
runtime cannot materialize NaN or ±Infinity floats (arithmetic raises,
parsing and the external term format reject them), so no non-finite value
can reach this module on a stock BEAM.
verify/2 enforces the interchange contract: bytes are canonical only if
decode followed by re-encode reproduces them exactly; anything else
(whitespace, member reordering, needless escapes, non-ES6 number lexemes)
is {:error, :non_canonical_bytes}. Digests are computed over exactly
these verified bytes by the digest layer.
The decoder's integer window (the integer-window amendment, 2026-08-21) closes the
former encode/verify asymmetry: a pure-digit lexeme above ±(2^53−1)
decodes as {:float, f} iff number/1 reproduces it, so canonical
pure-digit output for integral floats from 2^53 up to 10^21 (e.g.
9007199254740992, 295147905179352830000 — RFC 8785 Appendix B) now
round-trips through decode and verify/2. Above-bound lexemes that are
not a double's canonical ES6 spelling deny decode with
:number_not_double_expressible, which verify/2 passes through.
Errors are value-free: a reason names a category, never the offending bytes or values. Canonical bytes are a serialization fact — encoding never authorizes anything.
Summary
Functions
Encode value (the tagged algebra) to RFC 8785 canonical JSON under
bounds (a Bounds struct or tighten-only overrides, defaulting to the
profile maxima). The bytes ceiling bounds the OUTPUT size.
Serialize float per ECMA-262 §7.1.12.1 (Note 2 included): -0.0 is
"0", integral values below 10^21 print as integers, 1e+21 and beyond
(and below 10^-6) use exponential notation.
The RFC 8785 §3.2.3 member sort key: name as big-endian UTF-16 code
units. Erlang binary order over these keys is exactly the RFC's unsigned
code-unit comparison, shorter-prefix-first. name must be valid UTF-8.
Verify that input is already in canonical form: decode under bounds,
re-encode, and require byte equality. Returns the decoded value on
success, {:error, :non_canonical_bytes} on any mismatch, and passes
decoder errors through unchanged (so every Json decode reason —
:number_not_double_expressible included — can surface here).
Types
@type reason() :: :non_canonical_bytes | :integer_magnitude | AgentBlueprintProtocol.Json.reason()
Functions
@spec encode( AgentBlueprintProtocol.Json.value(), AgentBlueprintProtocol.Bounds.t() | map() ) :: {:ok, binary()} | {:error, reason()}
Encode value (the tagged algebra) to RFC 8785 canonical JSON under
bounds (a Bounds struct or tighten-only overrides, defaulting to the
profile maxima). The bytes ceiling bounds the OUTPUT size.
Serialize float per ECMA-262 §7.1.12.1 (Note 2 included): -0.0 is
"0", integral values below 10^21 print as integers, 1e+21 and beyond
(and below 10^-6) use exponential notation.
The RFC 8785 §3.2.3 member sort key: name as big-endian UTF-16 code
units. Erlang binary order over these keys is exactly the RFC's unsigned
code-unit comparison, shorter-prefix-first. name must be valid UTF-8.
@spec verify(binary(), AgentBlueprintProtocol.Bounds.t() | map()) :: {:ok, AgentBlueprintProtocol.Json.value()} | {:error, reason()}
Verify that input is already in canonical form: decode under bounds,
re-encode, and require byte equality. Returns the decoded value on
success, {:error, :non_canonical_bytes} on any mismatch, and passes
decoder errors through unchanged (so every Json decode reason —
:number_not_double_expressible included — can surface here).