The RAPP Programming Language Chapter 2

← Chapter 1: A Tutorial Introduction · Book contents · Chapter 3: Content Addressing →

Chapter 2 — Canonicalization

In this chapter: see why semantic equality is not byte equality, inspect canonical bytes, understand the reference profile’s number boundary, and learn which “helpful” normalizations a conformant consumer must refuse.

Every hash in RAPP is a hash of bytes. But agents exchange values — objects, arrays, strings, numbers. Between a value and its hash sits a question that has sunk more distributed systems than any other: which bytes? {"a":1,"b":2} and {"b":2,"a":1} are the same value and different bytes. If two implementations disagree about which byte string represents a value, they compute different hashes for the same content, and every downstream promise — content addressing, chaining, signatures — silently breaks.

Canonicalization is the rule that makes the answer unique. RAPP §4 adopts RFC 8785, JSON Canonicalization Scheme (JCS), because this is a solved problem and inventing a fourth JSON canonicalizer is exactly the kind of drift this protocol exists to end.

2.1 The Rules

A canonical RAPP value is I-JSON (RFC 7493) serialized by JCS. In practice:

The reference implementation is a direct transcription of these rules:

def canonical(v):
    if v is None or isinstance(v, bool):   return json.dumps(v)
    if isinstance(v, int):                 return json.dumps(v)
    if isinstance(v, float):               raise ValueError("floats need full JCS number form")
    if isinstance(v, str):                 return json.dumps(v, ensure_ascii=False)
    if isinstance(v, list):                return "[" + ",".join(canonical(x) for x in v) + "]"
    if isinstance(v, dict):
        keys = sorted(v.keys())
        if len(keys) != len(set(keys)):    raise ValueError("duplicate keys")
        return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical(v[k])
                              for k in keys) + "}"
    raise ValueError("non-I-JSON value")

You can watch the property it guarantees — the same value canonicalizes identically regardless of how it was constructed:

>>> R.canonical({"b": 1, "a": [3, 2]}) == R.canonical({"a": [3, 2], "b": 1})
True
>>> R.canonical([1, 2]) == R.canonical([2, 1])
False

Key order is erased; array order is preserved. This is conformance vector V1, and it is the foundation the whole tower stands on.

2.2 Numbers, and Why the Reference Profile Has None

Numbers are where JSON canonicalization gets genuinely hard. Is 1, 1.0, 1e0, and 10e-1 the same number? RFC 8785 specifies an exact IEEE-754 serialization (the ECMAScript Number.prototype.toString algorithm) so that every binary64 value has one canonical form. A production RAPP implementation MUST implement it, and the test is a round-trip: 0.1 must survive canonicalization unchanged.

The reference profile in rapp.py deliberately refuses floats and accepts only exact integers, strings, booleans, null, arrays, and objects. This is not a weaker canonicalizer; it is the same canonicalizer over the value domain where the answer is unambiguous on every platform. The reference vectors use integer payloads so the published hashes are reproducible byte-for-byte anywhere, on any language, without depending on a float-formatting library. When you need floats in real payloads, implement RFC 8785 §3.2.2 and keep the round-trip test in your conformance suite. When you can express a quantity as an integer or a decimal string, do — it is one less thing that can differ between two honest implementations.

2.3 What Canonicalization Does Not Do

Two temptations, both refused, both for the same reason: they make the same bytes hash differently on different machines.

2.4 The Payoff

Because canonicalization is exact and shared, everything above it can be exact and shared. The first chapter 10 audit ran the reference canonical() against 32 historical frames written by a different program and reproduced every stored payload hash byte-for-byte. After migration, the current sweep reproduces the domain-tagged addresses of 46/46 frames. That is the whole point: independent producers and consumers agree because JCS has exactly one answer.

2.5 Failure Modes: Refuse, Do Not Normalize

Input Why it is unsafe Consumer action
duplicate object member parsers may keep the first or last value refuse the whole value
lone UTF-16 surrogate not interoperable I-JSON text refuse
non-round-tripping number two runtimes may see different mathematical values refuse
value deeper than 64 levels parser/resource attack surface refuse
canonical form over 1 MiB unbounded hashing and memory cost refuse
visually equal but code-point-different strings normalization would change addressed bytes preserve as distinct values

Canonicalization is not cleanup. A consumer that silently repairs an unsafe value may hash bytes the producer never sent and then claim agreement.

2.6 Checkpoint: Inspect the Bytes

From the repository root:

python3 - <<'PY'
import rapp as R

a = {"message": "café", "n": 1, "items": [3, 2, 1]}
b = {"items": [3, 2, 1], "n": 1, "message": "café"}

ca, cb = R.canonical(a), R.canonical(b)
print(ca)
print(ca.encode("utf-8").hex())
print("same bytes:", ca.encode("utf-8") == cb.encode("utf-8"))
PY

Observe three things: the object construction order disappears, the array order remains, and the non-ASCII é is emitted as UTF-8 rather than a \u escape.

Then replace integer 1 with float 0.1. The small reference profile refuses it. That refusal does not mean RAPP forbids all binary64 values; it means a production implementation that accepts them must implement the complete RFC 8785 number form rather than inheriting a language’s default formatter.

2.7 Exercises

Exercise 2-1. Predict the canonical text for five values before running the code: an empty object, an empty array, two objects with reversed construction order, and two arrays with reversed element order.

Exercise 2-2. Build a fixture table containing the input value, canonical text, and UTF-8 hex for nested values and non-ASCII strings. A selected solution appears in Appendix C.

Exercise 2-3. Explain why Python’s bool being a subclass of int can be dangerous in a validator. Find the guards in rapp.py that prevent True from becoming a valid seq.

Exercise 2-4. Write a pre-walk that refuses nesting depth greater than 64 and canonical output larger than 1 MiB without partially accepting the value.

Exercise 2-5. Advanced: compare two full RFC 8785 libraries on the boundary values 0.1, -0, 9007199254740991, 9007199254740993, and 1e999. Record acceptance and canonical bytes.

2.8 Chapter Summary

Next we turn canonical bytes into addresses — carefully, so a payload address cannot be confused with a frame address even when the underlying bytes are identical.


← Chapter 1: A Tutorial Introduction · Book contents · Chapter 3: Content Addressing →