Two JSON documents, same value:

{"name":"ari","roles":["builder","writer"]}
{
  "roles": ["builder", "writer"],
  "name": "ari"
}

Most programs call those one object. A raw content hash calls them two.

I spent more of my time on the bytes before the hash than on the hash itself. SHA-256 and BLAKE3 are the easy part. Give either one a byte string and it returns a stable digest. The awkward part starts when the thing I care about is not an exact byte string but a typed value that can be written more than one way.

So there are really two choices. Formatting becomes part of identity, or the system defines a canonical representation and hashes that instead.

I want the second property, but I do not want a storage primitive pretending it understands “meaning” in the broad human sense. The useful claim is narrower:

Within a declared type and canonicalization standard, equivalent values derive the same address.

That is the boundary UOR-ADDR is trying to make precise.

Everything before the hash is the hard part

For an exact byte sequence bb, a content digest is wonderfully clear:

digest⁡(b)=H(b)\operatorname{digest}(b) = H(b)

Change a byte and the digest almost certainly changes. That is exactly what I want for an executable, compressed archive, immutable image layer, or block in a content-addressed store. In those cases, the bytes are the object.

Typed values are messier.

JSON object order is not significant. Unicode can represent the same visible text with different code-point sequences. XML has namespace and attribute-order rules. ASN.1 defines canonical DER. Numbers cross language boundaries with different widths and coercions.

Hash the incoming representation directly and every one of those choices becomes an identity choice, whether the application intended it or not.

The pipeline therefore has to begin earlier:

flowchart LR
    B[Host bytes] --> P[Parse as declared type]
    P --> V[Validated typed value]
    V --> C[Canonical representation]
    C --> H[Cryptographic hash]
    H --> K[Stable address]

    P -. malformed .-> R[Refuse]
    V -. outside schema .-> R
    C -. cannot represent faithfully .-> R

For a type TT:

addr⁡T(x)=H ⁣(canon⁡T(x))\operatorname{addr}_T(x) = H\!\left(\operatorname{canon}_T(x)\right)

The subscript is doing real work. JSON canonicalization is not XML canonicalization. A photograph is not an article. A type tells the system which differences it is allowed to erase.

If x≡Tyx \equiv_T y means “equivalent under the declared rules for TT,” the intended invariant is:

x≡Ty⟹addr⁡T(x)=addr⁡T(y)x \equiv_T y \Longrightarrow \operatorname{addr}_T(x) = \operatorname{addr}_T(y)

Already a strong and useful property. Not a claim that the system has solved semantics in general.

What the canonicalizer forgets for you

I used to think of canonicalization as serialization cleanup. That understates the risk.

The canonicalizer decides which distinctions disappear before identity is minted. Collapse two different values and every system downstream receives the same address for objects that should have remained separate. Canonicalize differently in two language implementations and one object acquires two identities. Accept unbounded depth in the parser and the identity path becomes a denial-of-service surface.

The canonicalizer belongs on the trust boundary with the protocol parsers.

It needs a source standard, deterministic output, explicit size and depth limits, no locale-sensitive behavior, refusal when a value cannot be represented faithfully, and conformance vectors shared across implementations.

UOR-ADDR imports established canonical forms where they exist: JCS for JSON, canonical S-expressions, Canonical XML, and DER for ASN.1. Domain-specific descendants can import existing schemas such as schema.org/Article, schema.org/Photograph, and in-toto statements rather than inventing a private vocabulary for everything.

That restraint matters. A new identity layer should not casually become a new ontology for the entire world.

Sometimes the right answer is to refuse

JavaScript makes the danger easy to see.

What should a JSON address function do with undefined, a function, a cycle, a non-finite number, or an integer that cannot survive the target representation safely?

The tempting answer is to coerce the value until something serializes. Stable, portable, and possibly wrong.

The safer path is to refuse:

flowchart TD
    X[Input value] --> F{Faithful canonical form?}
    F -- yes --> N[Normalize]
    N --> S[Stream canonical bytes]
    S --> A[Derive address]
    F -- no --> E[Typed error]

A stable wrong address is worse than no address. It carries the appearance of certainty into every registry, cache, and signature that follows.

Unicode needs the same care. NFC normalization can be appropriate when the declared type says canonically equivalent code-point sequences are the same value. Case folding, stemming, or transliteration would erase much broader differences, and should only happen when a specific type defines them.

There is no universal “make these values mean the same thing” function. There are typed equivalence rules with explicit boundaries.

Identity and admission are different jobs

Another distinction became important as the design grew: the address says which canonical value this is; a witness says which additional predicate was checked.

Suppose a JSON value is admitted once as generic JSON and once as an article that must satisfy a schema. The underlying canonical object can keep the same address while the article-specific admission produces a separate witness.

flowchart LR
    J[Typed JSON value] --> C[Canonical JSON]
    C --> K[Address]
    J --> S{Article schema}
    S -- valid --> W[Article witness]
    S -- invalid --> X[Refuse article admission]
    K --> W

This keeps the identifier from trying to carry identity, authorization, provenance, mutable naming, and every semantic relationship at once.

The address names the object. The schema witness records a checked claim about it. A registry edge, namespace root, capability, or signature records how the object is being used.

Those layers can evolve independently because they share a stable subject.

Rust agreeing with Rust is not enough

A Rust implementation agreeing with itself proves nothing.

The same object may cross a C ABI, a WASM component boundary, a Python process, and a TypeScript application. Every ecosystem has different default serializers and number types. If each binding chooses its own interpretation, “universal address” becomes branding rather than a property.

The conformance target is byte-level agreement:

flowchart TB
    V[One admitted value]
    V --> R[Rust]
    V --> C[C ABI]
    V --> W[WASM component]
    V --> P[Python]
    V --> T[TypeScript]

    R --> K[Same address bytes]
    C --> K
    W --> K
    P --> K
    T --> K

This is where hidden differences surface: one runtime normalizes Unicode and another does not; one accepts a number another refuses; one sorts keys by code point while another uses a locale; one binding truncates a value or allocates through a supposedly bounded path.

Language bindings are not packaging work. They are part of the identity proof.

How far the address travels

The simplest canonicalizer builds a complete normalized document in memory and hashes it afterward.

That works until the object is large or the verifier is small.

UOR-ADDR’s core design streams canonical bytes into the hash and preserves no_std and allocator-free paths where the format permits them. An address function should not have to own the object it names, and a canonical form should not require a server-sized temporary buffer merely to exist.

The ideal working memory is driven by bounded parser state rather than total input size:

M(n)=O(d+w)M(n) = O(d + w)

Here, dd is the allowed structural depth and ww is the fixed working state for canonicalization and hashing.

Not every format reaches that bound cleanly. Sorting object keys may require materialization or an external ordering strategy. What matters is stating the allocation boundary honestly instead of hiding a Vec inside an ergonomic wrapper and calling the whole path allocation-free.

That honesty is what allows the same identity function to work in a browser worker, embedded verifier, native service, and constrained runtime.

Write the same value twice

Before changing an address format, make this tiny test fail in the old design:

  • write the same typed value two different ways
  • parse both values under the same declared type
  • compare their canonical bytes
  • hash those bytes and check that the addresses match
  • add one difference the type does not permit and make sure it refuses

The useful question is not “did the hash change?” It is “did I decide, deliberately, whether this difference belongs to identity?”

A stable address still doesn’t tell you much

Even a perfect typed address does not tell me who published the object, whether I should trust it, who may read it, whether it is current, where its bytes live, or whether a computation over it was correct.

It should not try. Those are relationships and claims around the object, and they belong in signatures, capabilities, typed edges, namespace roots, provenance records, and execution witnesses.

The address gives all of those systems one durable thing to point at. Two systems can agree on which object they mean without first agreeing on a database row, bucket path, host name, or serializer.

That agreement only survives if the address stays conservative: erase no difference the declared type did not authorize, refuse values that cannot be represented faithfully, and require every binding to produce the same bytes.

A raw hash identifies bytes.

A typed object address has to name one canonical representative of a declared equivalence class. That is a smaller claim than saying the address understands meaning, but it is one I can implement, test, and carry across systems.

The part that decides whether any of this is real is the boring half. Conformance vectors, refusal paths, depth limits. My rule is simple and slightly unforgiving: a binding that has not passed the vectors does not ship. An address scheme is only as trustworthy as the least careful implementation that can mint one, and being nearly right about a digest earns no credit.