AgentBlueprintProtocol.Canonicalization (Agent Blueprint Protocol v0.1.0)

Copy Markdown View Source

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

reason()

@type reason() ::
  :non_canonical_bytes
  | :integer_magnitude
  | AgentBlueprintProtocol.Json.reason()

Functions

encode(value, bounds \\ Bounds.maximum())

@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.

number(f)

@spec number(float()) :: {:ok, binary()}

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.

sort_key(name)

@spec sort_key(binary()) :: binary()

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(input, bounds \\ Bounds.maximum())

@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).