Skip to content

The Drift Log · Model independence and repeatable builds

Pinning Dictionary Iteration Order for Byte-Identical Artifacts

31 October 2026 · 4 min read · 839 words · established

Two identical sealed archive crates with matching checksum plates

Runtime hash table randomization silently breaks reproducible builds. Here is how to pin serialization boundaries down to the byte in CI.

You run two builds from the same commit on the same machine five minutes apart. Git status is clean. The compiler toolchain is pinned down to the patch version. Yet the SHA-256 checksum of the final release artifact differs.

When you decompress both tarballs and inspect the diff, the logic is identical. Every function, variable, and constant matches. The divergence is twenty lines of generated manifest metadata where four dictionary keys swapped positions: {"author": ..., "version": ...} became {"version": ..., "author": ...}.

The software runs the same way. But in a verification pipeline that depends on cryptographic hashes, a swapped pair of keys invalidates the entire artifact.

The Random Walk of In-Memory Hash Tables

Most modern runtimes deliberately randomize hash table iteration.

In Go, the runtime initializes a random seed at startup and offsets map iteration by a pseudo-random bucket index. Two consecutive loops over the exact same map in the same binary can yield different key orders. Python has randomized its PYTHONHASHSEED on startup since version 3.3 to defend against hash-collision denial-of-service attacks. Rust’s default std::collections::HashMap uses SipHash 1-3 with randomly generated keys per instance.

These defenses protect network-facing services against algorithmic complexity attacks. But when these same data structures are used inside a code generator, an intermediate AST serializer, or an asset packager, that deliberate entropy bleeds directly into the artifact.

If a step in your build pipeline collects items into a standard hash map and writes them out to a header, an intermediate JSON payload, or an archive table of contents, the output order is effectively random.

## Unstable: output depends on process hash seed
def emit_manifest(metadata: dict) -> str:
    return json.dumps(metadata)

## Stable: output is byte-identical across all invocations
def emit_manifest_canonical(metadata: dict) -> str:
    return json.dumps(metadata, sort_keys=True, indent=2)

In compiled languages, using an unsorted collection during code generation introduces identical jitter. Generating C headers from an AST stored in an unordered map results in fluctuating struct field layouts or swapped function declarations, which changes the debug symbols and object file layouts downstream.

The Cost of Unpinned Order

Non-deterministic iteration causes silent failures in build engineering:

  1. Broken build caching: Build systems like Bazel or Turborepo rely on input and output hashes to decide whether a target needs rebuilding. An unstable intermediate artifact busts cache hits for every dependent step.
  2. Flaky tests in contract suites: Snapshot tests and end-to-end integration tests that compare raw outputs against golden files fail intermittently depending on which process seed the test runner initialized.
  3. Compromised verification: Achieving reproducible builds requires identical input to yield identical output bit-for-bit. If the binary hash changes between runs, you cannot independently verify that an artifact matches its source code. Establishing a valid root of trust becomes impossible.

The failure is rarely catastrophic at runtime, which is why it lingers. The software boots, passes its smoke tests, and ships. But treating reproducible builds as an optional property opens the door to ambient variance throughout your stack.

As we argue in our post on why model independence is an engineering posture, eliminating ambient variance is the central requirement of deterministic software. Logic must be computed rather than guessed, and serialization must be pinned down to the byte.

Auditing the Serialization Boundary

Fixing unordered serialization requires making sorting a hard invariant at every boundary where in-memory state is converted to bytes.

1. Swap Default Maps for Ordered Collections in Build Tools

If you are writing build tools, generators, or macro expanders in Rust, use BTreeMap or IndexMap instead of standard HashMap when the collection will be iterated to produce output. In Go, sort the keys explicitly before iterating:

// Sort keys explicitly before writing
keys := make([]string, 0, len(data))
for k := range data {
    keys = append(keys, k)
}
sort.Strings(keys)

for _, k := range keys {
    builder.WriteString(fmt.Sprintf("%s: %s\n", k, data[k]))
}

2. Enforce Canonical Serialization Flags

Standard library JSON, YAML, and XML serializers rarely sort keys by default. Configure your serializers explicitly:

  • Python: json.dumps(obj, sort_keys=True)
  • Go: json.Marshal sorts map keys by default, but custom struct serializers often do not. Verify custom marshalers.
  • Node.js: JSON.stringify preserves object insertion order. If properties were added via dynamic assignment or unordered keys, sort the object keys before serialization, or use a canonical JSON serializer like fast-json-stable-stringify.

3. Pin Archive Invariants

Creating a .tar or .zip archive is itself a serialization step. Adding files to an archive in directory-traversal order copies the file system's inode order directly into the archive headers. Always sort the file list lexicographically and strip timestamps or pin them to SOURCE_DATE_EPOCH before feeding paths into tar.

What to Put in CI on Monday

Do not rely on code reviews to catch unordered map traversals. Add a reproducibility check to your continuous integration pipeline:

  1. Build the release target once from a clean workspace and record the hash of the resulting artifact.
  2. Clean the environment, change the working directory path, set a distinct PYTHONHASHSEED, and build the exact same commit a second time.
  3. Compare the checksums of the two artifacts.
  4. If the checksums differ, use diffoscope to compare the outputs. It will deconstruct the archives, compare intermediate sections, and pinpoint the exact file, manifest, or symbol table where ordering diverged.

Eliminating map iteration variance is not an optimisation. It is a baseline invariant for any team that expects their build outputs to be strictly provable.

Keep reading

Next in the log

The Strategic Master Library · written and reviewed under the house's own epistemic rules: nothing claimed that we cannot show.