faceless-photolib

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 always v1. 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 always b3 (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:

RoleAuthoritative?Meaning
masteryesThe archival source — deleting it loses data.
documentyesA canonical documents/<hash>.json state.
historyyesA history/<hash>.json node.
previewnoDerived, rebuildable from a master. A package stays valid without it (except the one mandatory previews/flat.png — see Package anatomy).
cachenoA 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:

  1. 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.
  2. 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.
  3. Strings escape exactly as JSON.stringify would (RFC 8785 §3.2.2.2).
  4. 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 (Date instances) 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 as unsupported-value rather than silently dropped the way JSON.stringify drops undefined — 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.