I keep spending more time arguing with the bytes before a hash than with 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.
These two JSON documents have different bytes:
{"name":"ari","roles":["builder","writer"]}
{
"roles": ["builder", "writer"],
"name": "ari"
}
Most programs treat them as the same object. A raw content hash does not.
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.
The hash solves the last step
For an exact byte sequence , a content digest is wonderfully clear:
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.
If the incoming representation is hashed directly, all of those choices become identity choices whether the application intended them to 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 :
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 means “equivalent under the declared rules for ,” the intended invariant is:
That is already a strong and useful property. It is not a claim that the system has solved semantics in general.
Canonicalization decides what the system forgets
I used to think of canonicalization as serialization cleanup. That understates the risk.
The canonicalizer decides which distinctions disappear before identity is minted. If it collapses two different values, every system downstream receives the same address for objects that should have remained separate. If two language implementations canonicalize differently, one object acquires two identities. If a parser accepts unbounded depth, 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.
Refusal is part of the address
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. The result is 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 because 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 should not be fused
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.
The real conformance test crosses languages
A Rust implementation agreeing with itself is not enough.
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 just packaging work. They are part of the identity proof.
Streaming determines where the address can travel
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:
Here, is the allowed structural depth and 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. The important thing is to state 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.
Try it with two representations
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 is only the beginning of the story
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. 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.
That is the payoff I care about. Two systems can agree on which object they mean without first agreeing on a database row, bucket path, host name, or serializer.
But the agreement only remains useful if the address is 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 identifies 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.