Ref grammar + canonical hashing
The v1:b3:<hex> ref string grammar, the RFC 8785 (JCS) canonicalization profile, and the unknown-fields rule — from @faceless-photolib/canonical-json and @faceless-photolib/content-store.
Every reference to stored content in the format is a typed, versioned ref — never a bare hash string. This page is the grammar a hand-written tool needs to produce and verify refs without reading the source.
The ref string: v1:b3:<hex>
ref-string = version ":" algo ":" digest
version = "v1"
algo = "b3"
digest = 64hexdig ; lowercase only
hexdig = %x30-39 / %x61-66 ; "0"-"9" / "a"-"f"version— today alwaysv1. A future hashing scheme or ref-shape change bumps this, so old history is never silently reinterpreted under a new meaning. Parsing any other prefix returns a typed{ kind: "unknown-version", version, input }, never a guess.algo— today alwaysb3(BLAKE3-256). An unrecognized algo segment returns{ kind: "unknown-algo", algo, input }.digest— exactly 64 lowercase hex characters (a BLAKE3-256 digest). Anything else — wrong length, uppercase, non-hex characters — is{ kind: "malformed", detail, input }.
Bare, unversioned hashes must never appear in any persisted structure —
every field that names content is a full ref string or a full ContentRef
object, by schema.
The ContentRef object
Where a reference needs metadata alongside the digest (manifests, document
sources, history-node inputs/outputs), it's a ContentRef, not a plain
ref string:
type ContentRef = {
algo: "b3";
digest: string; // 64 lowercase hex chars
mediaType: string; // "type/subtype", e.g. "image/png"
role: "master" | "preview" | "cache" | "document" | "history";
bytes: number; // non-negative integer, the payload's byte length
};role is load-bearing, not decorative:
| Role | Authoritative? | Meaning |
|---|---|---|
master | yes | The archival source — deleting it loses data. |
document | yes | A canonical documents/<hash>.json state. |
history | yes | A history/<hash>.json node. |
preview | no | Derived, rebuildable from a master. A package stays valid without it (except the one mandatory previews/flat.png — see Package anatomy). |
cache | no | A derived, rebuildable representation (e.g. a GPU-transcoded tier). Never the source of validity. |
A ref whose role contradicts where it's used (a preview-role ref sitting
in manifest.rootDocument, say) is a validator finding
(role-mismatch) — roles are checked, not just documented convention.
Canonicalization: the RFC 8785 (JCS) profile
Content is hashed as canonical JSON bytes, never as whatever a producer's
JSON.stringify happened to emit — otherwise two writers could serialize the
same logical document to different bytes and get different (and therefore
unrelated) hashes for what should be identical content.
The rules, in order:
- Object keys sorted lexicographically by UTF-16 code units (JavaScript's
native
Array.prototype.sort()order over the raw, unescaped key strings) — not insertion order, not any other collation. - Numbers render in ECMAScript shortest-round-trip form. RFC 8785 defers
to
JSON.stringify's number formatting for exactly this reason, and that's what this implementation calls per scalar. - Strings escape exactly as
JSON.stringifywould (RFC 8785 §3.2.2.2). - Output is encoded as UTF-8 bytes — the hash input is bytes, not a JS string.
On top of the JCS profile, the format adds explicit rejections — never silent coercions:
- Non-finite numbers (
NaN,Infinity,-Infinity) inside hash-participating state are rejected:{ kind: "non-finite-number", path, value }. - Timestamps (
Dateinstances) are rejected the same way (valueType: "timestamp") — a wall-clock value inside hashed state would make the hash non-reproducible across serializations of "the same" data. undefined, functions, symbols, bigints, and non-plain objects (Map,Set, class instances, …) are rejected asunsupported-valuerather than silently dropped the wayJSON.stringifydropsundefined— a canonicalizer that silently drops fields would let two logically-different values hash identically.
Unknown fields are canonical state
A .passthrough() schema's unknown fields are preserved and hashed —
"preserve-and-hash," never a second "known-fields-only" hash a future writer's
extra data could slip past. This is the same rule Package
anatomy describes for manifests/indexes, restated at the
hashing layer: whatever bytes get hashed are whatever bytes get preserved,
full stop.
Hashing: BLAKE3-256, chunked for large payloads
Content hashes with BLAKE3-256 via @noble/hashes — pure JS, no WASM, so it
runs identically in a Web Worker, Node, and React Native. Payloads over 1 MiB
hash as fixed 1 MiB chunks combined into a Merkle root
(parent = blake3(0x01 ‖ left ‖ right), an odd trailing leaf promoted
unchanged); a single-chunk payload's root equals its direct BLAKE3 digest, so
small and large payloads agree at the boundary — there is no special case a
caller needs to know about.
API sketch
import {
canonicalRef,
parseRefString,
refForPayload,
} from "@faceless-photolib/canonical-json";
// Canonicalize + hash a JSON value in one step:
const result = canonicalRef({ b: 2, a: 1 });
// { kind: "ok", ref: "v1:b3:...", digest: "...", bytes: Uint8Array } — key
// order in the input never affects the ref.
// Hash raw bytes (a blob, not a JSON document) straight to a ref string:
const blobRef = refForPayload(pngBytes);
// Parse an untrusted ref string:
const parsed = parseRefString("v9:b3:...");
// { kind: "unknown-version", version: "v9", input: "v9:b3:..." }Every function here returns a typed discriminated-union result — nothing in
@faceless-photolib/canonical-json throws on malformed input.