I have lost more time to identifiers than to hashing.
The hash function is usually the easy part. Give SHA-256 or BLAKE3 a byte string and it gives you a stable digest. The trouble starts one step earlier: deciding which bytes are allowed to stand for the thing.
Consider these two JSON documents:
{"name":"ari","roles":["builder","writer"]}
{
"roles": ["builder", "writer"],
"name": "ari"
}
Their byte hashes differ. Most applications would treat them as the same value.
That leaves an awkward choice. Either the identifier changes whenever formatting changes, or the system needs a rule for turning equivalent representations into one canonical form before hashing them.
I want the second property, but with a hard boundary around it.
“Same meaning” is too large and too philosophical for a storage primitive. A reliable address needs a narrower claim:
Within a declared type and canonicalization standard, equivalent values derive the same address.
That is the problem UOR-ADDR is trying to make precise.
A digest is not yet an object identity
A normal content hash has an excellent guarantee:
Here, is an exact byte sequence and is the selected hash function. Change one byte and the digest almost certainly changes.
That is ideal when the byte sequence is the object: a compressed archive, a block in a content-addressed store, an executable, or an immutable image layer.
It is less satisfying when the bytes are one serialization of a typed value.
JSON object key order is not significant. Unicode can encode the same visible text in multiple normalization forms. XML has namespace and attribute-order rules. ASN.1 has canonical DER encodings. Numbers can arrive through representations that look different while denoting the same value.
If the system hashes the incoming bytes directly, those representation choices become identity choices whether anyone intended them to or not.
The address pipeline I want looks like this:
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 useful definition is:
The subscript matters. JSON canonicalization is not XML canonicalization. A document schema is not a photograph schema. The type tells the system which equivalence relation it is asserting.
If means “equivalent under the declared rules for ,” then the intended invariant is:
The reverse direction is bounded by the collision resistance of the hash, not proven by the canonicalizer. That distinction should stay visible.
Canonicalization is part of the trust boundary
It is tempting to treat canonicalization as serialization cleanup. It is not.
The canonicalizer decides which differences disappear before identity is minted.
A bug there can collapse two distinct values onto the same canonical byte sequence. A permissive conversion can silently throw away information. An inconsistent implementation can produce different addresses in different languages. A recursive parser can become a denial-of-service surface. Unicode handling can split identity between visually identical strings.
The canonicalizer therefore needs the same kind of discipline I would expect from a protocol parser:
- a published source standard
- explicit depth and size bounds
- deterministic output
- no locale-sensitive behavior
- a refusal path for values without a faithful representation
- cross-implementation conformance vectors
- no hidden “helpful” coercions
UOR-ADDR imports existing canonical forms where they exist: JCS for JSON, Rivest’s canonical S-expressions, Canonical XML, and DER for ASN.1. Domain-specific descendants import existing schemas such as schema.org/Article, schema.org/Photograph, and in-toto statements rather than inventing parallel vocabularies.
That choice matters. A new identity system should not casually become a new ontology for everything it touches.
Refusal is better than accidental equivalence
JavaScript provides a good example of the failure mode.
A naïve JSON canonicalizer may encounter values that do not survive a faithful JSON round trip:
undefined- non-finite numbers
- unsafe integers
- functions
- cyclic structures
The dangerous response is to coerce them until something serializes.
The useful response 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 looks trustworthy and travels well.
The same principle applies to Unicode. Normalizing to NFC is reasonable when the declared type says canonically equivalent Unicode sequences should share identity. It would be wrong to apply broader linguistic transformations—case folding, stemming, transliteration—unless the type explicitly defines them.
There is no universal canonicalization function. There are typed canonicalizations with declared losses, preferably none.
Schema admission and identity are different jobs
Another design choice I care about is keeping admission separate from the address itself.
Suppose two JSON documents have the same canonical structure, but one is being admitted as a generic JSON value and the other as an article that must satisfy a schema. The schema can decide whether the value is valid for that use without changing the underlying canonical address.
The pipeline becomes:
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 avoids stuffing every contextual claim into the identifier.
The address says which canonical value this is. The witness says which additional predicate was checked. The namespace, registry edge, signature, or application record says how the object is being used.
Trying to make one string carry identity, authorization, provenance, mutable naming, and every semantic relationship usually produces an identifier nobody can reason about.
Cross-language identity is the real test
A Rust implementation that reproduces itself is not enough.
If the same typed object crosses a C ABI, a WASM component boundary, a Python process, and a TypeScript application, its identity should not change because each ecosystem has different default serializers.
That is why I like conformance at the byte boundary. The question is not whether each binding exposes a similar API. The question is whether all paths emit the same fixed address bytes for the same admitted value.
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
That constraint quickly exposes hidden behavior:
- one runtime normalizes Unicode and another does not
- one number parser accepts values another refuses
- one object serializer sorts keys by code point and another by locale
- one binding truncates or allocates through an unintended path
The cross-language surface is not just distribution work. It is part of the identity proof.
Streaming changes what can be addressed
Canonicalization is often implemented by building an entire normalized document in memory and hashing it afterward.
That is convenient. It also means the largest addressable object is bounded by the allocator and by the temporary duplication the pipeline creates.
UOR-ADDR’s core design streams canonical bytes through the hashing pipeline and keeps no_std and allocator-free paths where the format allows it. That is interesting for two reasons.
First, an address function should not need to own the object it names.
Second, memory behavior is part of interoperability. A canonical form that only exists as a giant temporary buffer may work on a server and fail on a browser worker, an embedded device, or a constrained verifier.
The ideal memory bound is driven by parser state rather than total input size:
Here, is the bounded structural depth and is the fixed working state needed by the canonicalizer and hash—not the full input length .
Not every format can reach that bound without tradeoffs. Object-key sorting, for example, may require materialization or an external ordering strategy. The important part is to state the allocation boundary honestly instead of applying a no_alloc label to an ergonomic wrapper that builds a Vec first.
An address is still not the object’s whole story
A semantic address does not tell you:
- who published the object
- whether you should trust it
- who may read it
- whether it is current
- which other objects it depends on
- whether a computation over it was correct
- where its bytes are stored
It should not.
Those are relationships and claims around the object. They belong in signed records, typed edges, capability grants, namespace roots, provenance chains, and execution witnesses.
The address gives those systems a stable point to refer to.
That is the architectural payoff. Once two systems can agree on which object they mean without agreeing on a database row, bucket path, server hostname, or serializer, a lot of other coordination becomes simpler.
But the simplification only holds if the address is conservative.
Do not erase a difference unless the declared type says the difference is irrelevant. Do not accept a value you cannot canonicalize faithfully. Do not let language bindings choose their own identity rules. Do not confuse a stable address with trust.
A hash identifies bytes.
A typed object address identifies one canonical member of a declared equivalence class.
That is a smaller claim than “the address understands meaning.” It is also a claim a system can test.