Synthesis & recommendations
The capstone: what twenty-one systems agree and disagree about, the seam falsification gate, the two places this survey contradicted a decision we had already made, and what sparkles:build-primitives should adopt.
Last reviewed: September 21, 2026
The per-subject breakdown by composition, ordering, metadata floor and verification granularity lives in the umbrella's taxonomy tables; this page argues rather than tabulates.
Axis 1 — what is in the identity
The field agrees on the exclusion and disagrees on everything above it. Every subject but two excludes timestamps — Nix because they "cause FSOs that Nix should consider equal to hash to different values on different machines", OSTree because hardlinked checkouts must compare as up to date. The exceptions are instructive: REAPI can carry an mtime as an optional, server-declared node property, and OCI carries one because tar does and nobody stopped it.
Above that floor the spread is the whole design space — from Bao's and Venti's nothing at all, through one executable bit (NAR, Snix), to OSTree's uid/gid/mode/xattrs inside the content hash, to casync's genuinely unusual answer: the node model is a feature flag, so the same tool emits a timestamp-free archive or a fully faithful one.
Reproducible archives supplies the negative proof. Retrofitting canonicity onto tar and zip converges on NAR's node model by deletion — strip-nondeterminism's zip.pm and ar.pm both collapse modes to 755/644 and uid/gid to 0, arriving at name + bytes + one executable bit by overwriting fields rather than by not having them.
Axis 2 — entry ordering
No single canonical order exists. Four canonical answers, two deliberately non-canonical ones, one subject that never orders, and one that versioned its comparator — tabulated in the umbrella. The two that matter most disagree on real inputs: given a directory a and a file a.b, . is 0x2E and / is 0x2F, so git orders a.b first while a byte-wise sort orders a first (worked against real git).
The /-suffix rule now has four independent implementations — git's own base_name_compare, Nix's git.cc (whose header comment cites irmin hitting it too), Software Heritage's directory_entry_sort_key (which appends b"/" rather than substituting, arriving at the same function from the other side), and bup, whose .bupm layout explicitly accounts for it. A claim that survives four reimplementations is as solid as this catalog gets.
It is also not free. Because the implicit slash can separate two spellings of one name, git's duplicate detection needs a stack rather than an adjacent-pair check — fsck.c documents the foo / foo.bar / foo.bar/ / foo/ case — where NAR's name <= prevName suffices. The partitioned answers (OSTree's two arrays, REAPI's and Snix's three) make the question structurally unaskable.
Venti contributes the hazard's endgame: vac's metablock selects its comparison predicate from the block magic (mb->unbotch), because a known-wrong comparator could not be fixed in place — the archives are immutable. An ordering bug in a content-addressed format is permanent.
Axis 3 — serial versus Merkle
Everything a build system wants follows from the Merkle side: subtree caching, O(depth) rehash after a one-file edit, independent verification of a fragment, parallel hashing across files. Mercurial is the field's controlled experiment — it shipped a flat whole-tree manifest, hit O(|repo files|) parse cost, and moved to per-directory trees, stating the bound in its own retrospective: O(|changed files| * |directory size| * |directory depth|), which with capped fan-out and depth is O(|changed files|).
Venti settles a question the concepts page could only assert. Its directories are "a special file" of concatenated 40-byte VtEntry structures, so its level-1 tree is its level-2 pointer tree with a different block-type base. The levels do not nest; they are the same machine run twice.
Axis 4 — the level-2 surprise
Intra-file Merkle trees are neither new nor peer-to-peer-specific. Venti had pointer-block trees in 2002; REAPI added SHA256TREE in 2023 for chunk-level integrity and SIMD parallelism that a serial SHA-256 cannot offer.
The design consequence is concrete and load-bearing: any seam that hands a scheme a whole-file digest, or a fixed-size push stream of content, forecloses this entire class. A scheme must read its own bytes, in its own order, at its own concurrency.
Snix then supplies the best answer in the survey to where chunking belongs. Blob identity is plain BLAKE3 of the raw bytes — no length prefix, no blob <len>\0 framing — with FastCDC running strictly below identity as a storage and transport concern. Their write-up names the disease that casync and unixfs both have:
The chunking parameters, and the "topology" of the graph structure itself "bleeds" into the root hash.
Keep chunking below the identity and the parameters stop being part of the answer.
Axis 5 — what travels with a digest
REAPI puts size_bytes inside Digest; iroh verifies that a validated final leaf ends exactly at the claimed size; Snix independently carries a size at both levels (FileEntry.size, DirectoryEntry.size). Three independent derivations of the same guard.
The counterweight is Software Heritage, which carries no length at all and welds SHA-1 into the identifier grammar itself (<object_id> ::= 40 * <hex_digit>), with agility available only by breaking the scheme version. It is the strictest position in the survey — and it length-prefixes exactly one thing, snapshot alias targets, for disambiguation rather than truncation detection. That yields a crisp rule: fixed-width digests are what buy delimiter-free concatenation; any entry kind with a variable-length target needs its own length field.
Genuine algorithm agility exists only in REAPI (seven functions, negotiated) and the CID family (multihash-tagged, so a digest stays interpretable when separated from the system that made it).
Axis 6 — partial verification
Falls out of axes 3 and 4 — with one distinction the survey earned the hard way. A tree verifies a 16 KiB range against a 32-byte root with nothing else transmitted, because the geometry follows from the length. An index (eStargz and SOCI) is a flat list of per-chunk digests that must be fetched whole before any byte can be checked, and whose own integrity hangs off an annotation or a side artifact. The index gives depth-2 trust; the tree gives O(log n).
APK v4 adds the dimension nobody else has: the tree can be a shipped artifact. A .apk.idsig comes in complete and stripped forms, so a verifier can check a range for the cost of a signature over a few hundred bytes instead of a full pass. A seam should let a level-2 tree arrive out of band rather than assuming each consumer recomputes one.
What the field agrees on
- Timestamps are not identity — every subject but REAPI (optional) and OCI (by accident).
- Canonical form must be specified and checked. NAR rejects unsorted input at the parser; git names each violation as an
fsckcode; REAPI makes "canonical form" a normative term. - Symlinks are values, not links. Every subject stores the target string and refuses to follow it.
- Byte-wise comparison, never locale or case folding — in every ordering answer that orders at all.
What it does not agree on
Ordering — nine answers (axis 2).
The metadata floor — from nothing to uid/gid/mode/xattrs.
Case and duplicate collisions — five answers, and only one detects at ingest without damaging the identity:
System Behaviour NAR name mangling: ~nix~case~hack~+ counter on restore, stripped on dumpgit permitted silently; fails at checkout on a case-insensitive filesystem REAPI "legal … but the Action may be rejected by the remote system upon execution" OCI whatever tar did; discovered on extraction SWH detect at ingest, keep the digest byte-exact via raw_manifest, rename only in the modelSoftware Heritage's answer deserves its own line because it is the only one that does not move the failure away from its cause and does not sacrifice fidelity:
compute_hashreturnssha1(raw_manifest)whenever a raw manifest is present, andcheck()refuses one that was not needed.Whether a canonical form can be enforced at all. Venti's zero truncation — trailing zero bytes stripped from data blocks, trailing zero scores from pointer blocks — is a value normalization rather than an ordering or framing rule, and it is the one canonical form in the catalog the format cannot enforce: the server cannot distinguish a truncated block from an untruncated one. It is a client convention.
What this survey contradicted
Two earlier decisions did not survive contact with the evidence, and one needs rewording.
1. The git-shaped identity is a genuine fork, not an obvious choice
We chose a git-shaped Merkle identity in large part for its two independent oracles (git write-tree, plus Nix's second implementation). Snix — a full Nix reimplementation, in production — deliberately went the other way, rejecting git's encoding as "very binary, error-prone and 'made-to-be-read-and-written-from-C'" and judging that SHA-1 "isn't really a hash function to fundamentally base everything on in 2023". It took REAPI's three-sorted-arrays partition and BLAKE3 instead.
The trade is now explicit:
| git-shaped | REAPI-shaped (Snix) | |
|---|---|---|
Ordering hazard (a vs a.b) | present; needs the /-suffix trick | structurally absent |
| Independent oracle | git write-tree, on every machine | none comparable |
| Hash function | SHA-1 or SHA-256, repository-wide | BLAKE3, parallel by construction |
| Canonicity rests on | a byte format | deterministic protobuf (which Snix itself flags as a risk) |
This is a decision to make deliberately rather than inherit. It does not change the shape of the recommendation — Merkle internally, NAR at the boundary — only which Merkle.
2. NarHash should be stored, not merely recomputable
The earlier wording said NAR's serial cost "is irrelevant there because the bytes are being streamed anyway". That holds on ingest only. Snix makes NarCalculationService a trait precisely so a remote store can answer instead of re-walking the tree and re-streaming every blob, and its PathInfo carries nar_size and nar_sha256 as stored fields. Store them beside the Merkle identity.
3. "Error at hash time" is not the only honest answer to a collision
Recommendation 6 below proposed refusing a colliding tree, on the grounds that the field's other answers all defer the failure. SWH's raw_manifest shows a fourth position — repair the model, never the identity — which matters for any consumer that must reproduce an upstream digest exactly and therefore cannot refuse the tree.
The falsification gate
The seam declares, per scheme: a required entry ordering, a metadata mask, a traversal mode (pre-order stream or post-order fold), and no content requests at all — schemes own their own byte I/O. Subjects were run through it on paper before any of it was written.
| Subject | Verdict | What it found |
|---|---|---|
| REAPI | ✅ fold, byte-wise, partitioned by kind | Finding 1 — the metadata mask must admit mtime |
| Bao | ✅ level-2 only, scheme-owned I/O | Justifies both "no pushed content" and "levels are independent" |
| OSTree | ⚠️ | Finding 2 — the request vocabulary needs an extension point (xattrs) |
| NAR vs git | ✅ both | Finding 3 — traversal mode decides whether a scheme scales with cores |
| Snix | ✅ fold, three arrays, BLAKE3 | Finding 4 — a stored NarHash field, not a recomputation |
| APK v4 | ⚠️ | Finding 5 — a scheme must be able to receive a level-2 tree, not only build one |
| Tahoe | ❌ out of scope | Finding 6 — see below |
Finding 1 — the metadata mask must admit
mtime. Timestamps stay out of identity. But a scheme cannot synthesize what the walk never requested, and themtimearrives in the samestatxalready issued for the executable bit, so admitting it to the mask costs nothing.Finding 2 — the request vocabulary must be open. OSTree's identity includes xattrs, which need
listxattr/getxattr— outsidereadDir/stat/readlink/readFile. Unsatisfiable requests should be a typed error, not a silent omission. Xattrs stay unimplemented in v1.Finding 3 — traversal mode is a performance decision. Pre-order streaming cannot use the walk's parallelism; post-order folding can.
Finding 4 —
NarHash/NarSizeare stored fields.Finding 5 — a tree may arrive out of band. APK v4 ships the Merkle tree beside the artifact; iroh stores an outboard. The seam should not assume the verifier hashes the bytes itself.
Finding 6 — Tahoe is the boundary of the model, and usefully so. Erasure coding means no stored artifact hashes to anything in the address, and
k,Nand segment size sit inside the key derivation, so identical bytes under different encoding policy get a different capability. A capability names a recipe with a fixed output, not a byte string. Our seam assumes a digest names bytes; it does not generalize to this, and should say so rather than pretend.
Recommendations for sparkles:build-primitives
- Internal identity: a Merkle tree hash — subtree values are what make incremental rehash, cross-run caching and parallel hashing possible. Which Merkle is now an open decision (see the contradiction): git-shaped buys
git write-treeas a free oracle; REAPI/Snix-shaped buys structural immunity to the ordering hazard and BLAKE3. NarHash/NarSizecomputed at the Nix boundary and then stored beside the Merkle identity, not recomputed on demand.- Name every ordering separately —
narOrder,gitTreeOrder,partitionedOrder— and pin the differences with theaversusa.bfixture. Never write acanonicalOrder. - Admit
noneas an ordering for consumers that rank rather than hash, and let the ordering choice select the emission shape (streamed entries versus a buffered sorted batch). Excludenoneat compile time for fold schemes. - Identity is byte-exact — no case folding, no normalization. Unanimous in the field, and the only position that interoperates.
- Detect case and normalization collisions at hash time and return an
Expectederror naming both entries — with an escape hatch in the shape of SWH'sraw_manifestfor a consumer that must reproduce an upstream digest exactly. - Carry the length beside the digest, at both levels. Three independent derivations; nearly free.
- Keep content out of the fileset machine, keep the metadata mask and request vocabulary open, and allow a level-2 tree to arrive out of band.
- Copy NAR's bounds —
narMaxDepth64,narMaxTag32,narMaxName255. Snix independently choseMAX_NAME_LEN = 255with the identical rationale. - Keep chunking strictly below identity if chunking is ever added, so its parameters never bleed into the root hash.
- The subtree cache must be an optimization, never an authority.git's
cache-treeis advisory — a lost node costs time. ccache's direct-mode manifest is the authority for a hit, and its documented hole (a header that "would have been used if it existed" is never recorded) is a false "unchanged", which is the one error class our staleness rule forbids. - Record provenance beside the digest, never inside it — the SWHID qualifier lesson. Dedup erases the path and origin a consumer may need.
Out of scope, and why
- Store paths,
.narinfo, signatures, compression —sparkles:nixbindslibnixstore-cand can compute these for real. - Typed-data Merkleization (Ethereum SSZ) — consensus objects, not file system objects.
- Confidentiality-preserving addressing (Tahoe) — a different problem, and finding 6 says why it does not fit the seam.