Record a collection's hash seed, asked for rather than defaulted - #135
Merged
Merged
Conversation
This was referenced Sep 11, 2026
maverox
added this pull request to stack #142
September 11, 2026 10:20
maverox
force-pushed
the
work/hash-seed-explicit
branch
from
September 14, 2026 10:38
1eea8e7 to
8f68a32
Compare
maverox
force-pushed
the
work/hash-seed-explicit
branch
from
September 14, 2026 10:43
8f68a32 to
7f49506
Compare
maverox
force-pushed
the
work/hash-seed-explicit
branch
from
September 14, 2026 13:38
7f49506 to
541d901
Compare
…an defaulted A `HashMap` or `HashSet` iterates in a different order every process, because `RandomState::new()` draws fresh keys per collection. Any order that reaches the wire differs between a recording and its replay for a reason that has nothing to do with the candidate. `deja::hash_seed(name)` returns a `BuildHasher` whose keys are recorded and replayed, so that whole class of difference goes away. `BuildHasher` is the seam, so ONE type covers every std hash collection -- HashMap, HashSet, and anything generic over `S: BuildHasher` including IndexMap. Nothing per-structure is needed. There is deliberately NO `Default` impl. A collection could be seeded implicitly by branching `Default` on ambient correlation state, and every cost that approach carried flowed from that one decision: `Default` runs on the hot path (495 constructions in the vendor tree), so it needed a per-correlation memo; the memo needed somewhere to live, which was a correlation-keyed registry; and reading ambient state from inside `HashMap::new()` put a thread-local borrow on a path that also runs during thread teardown, where it aborts the process rather than panicking. None of that is essential to recording a seed -- it is the cost of not being asked. Explicit is more invasive per site and needs no machinery at all, which is a good trade only because the set of collections whose order reaches the wire is small. Per-collection rather than per-correlation. A correlation-wide seed was a way to hold event volume down when every collection was covered; with a handful of named ones that pressure is gone, and per-collection attributes an order difference to the collection that caused it. The name is a rank-1 explicit tag on the existing callsite ladder, not a new naming scheme. A miss SYNTHESIZES rather than stopping, which is why this could not land before the miss arm existed. An earlier position held that a seed miss must fail-stop, because absorbing yields a fresh random seed and every downstream diff becomes noise. That argument was against nondeterminism, not against difference: a content-addressed seed is stable run to run, order-only differences are already absorbed by the comparator, and an order difference that changes behaviour is deterministic and therefore attributable. The seed boundary goes from the strongest case for fail-stop to the best case for synthesis. Predictable keys are safe for a structural reason, now asserted rather than argued: record and disabled modes never perform a lookup, so they never miss, so they never reach the miss arm. Synthesized keys exist only inside a replay harness. `record_and_disabled_modes_never_reach_the_miss_arm` fails loudly if that ever stops being true. SipHash-1-3 from `siphasher` rather than `DefaultHasher`, which is hardcoded to keys (0, 0) and whose algorithm std explicitly does not guarantee across releases -- record and replay can run different toolchains. Cargo.lock moves by exactly the seven lines that dependency adds. Four mutations, four kills, each by a distinct test: keys ignored by the hasher, a constant synthesized seed, a malformed image redrawing instead of failing, and both `draw_keys` tags made identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FmXkygUmueraF9oR4oqwS
maverox
force-pushed
the
work/hash-seed-explicit
branch
from
September 14, 2026 13:57
541d901 to
2997274
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A
HashMaporHashSetiterates in a different order every process, becauseRandomState::new()draws fresh keys per collection. Any order that reaches the wire — a serialized object's key order, a list built by iterating a set — differs between a recording and its replay for a reason that has nothing to do with the candidate.BuildHasheris the seam, so one type covers every std hash collection —HashMap,HashSet, and anything generic overS: BuildHasherincludingIndexMap. Nothing per-structure is needed.Why there is no
DefaultA collection could be seeded implicitly, by branching
Defaulton ambient correlation state —HashMap::new()keeps working and every collection is covered. That is what #111/#115 did, and every cost they carried flowed from that one decision:Defaultruns on the hot path (495 constructions in the vendor tree) → needs a per-correlation memoHashMap::new()puts a thread-local borrow on a path that also runs during thread teardown, where it aborts the process rather than panickingNone of that is essential to recording a seed. It is the cost of not being asked. So: no
Defaultimpl, and the type will not construct without the caller naming a seed.The honest trade: explicit does not touch fewer sites — both designs require typing the collection. It is more invasive per site and needs zero machinery. That is a good trade only because the set of collections whose order reaches the wire is small, and is found from order-only body diffs rather than guessed at.
Why per-collection
A correlation-wide seed was a way to hold event volume down when every collection was covered. With a handful of named ones that pressure is gone, and per-collection attributes an order difference to the collection that caused it. The name is a rank-1 explicit tag on the existing callsite ladder, not a new naming scheme.
Why a miss synthesizes — reversing an earlier position
I previously argued a seed miss must fail-stop: absorbing yields a fresh random seed and every downstream diff becomes noise. That argument was against nondeterminism, not against difference.
A content-addressed seed is stable run to run. Order-only differences are already absorbed by the comparator ("a permutation of an identical multiset is not a difference"), and an order difference that changes behaviour is deterministic and therefore attributable. The seed boundary goes from the strongest case for fail-stop to the best case for synthesis — which is why this could not land before #133.
The security argument, asserted rather than argued
A synthesized seed is derived from the collection's name and correlation, so anyone holding those can predict it. Randomized keys exist precisely to stop an attacker crafting colliding entries.
That is safe for a reason that does not depend on anyone being careful: synthesis is unreachable outside replay. Record and disabled modes never perform a lookup, so they never miss, so they never reach the miss arm.
record_and_disabled_modes_never_reach_the_miss_armfails loudly if that ever stops being true.SipHash-1-3 from
siphasherDefaultHasher::new()is hardcoded to keys(0, 0)and std exposes no keyed constructor. Prefixing aDefaultHasherwith the keys would compile, but std explicitly does not guarantee its algorithm across releases — and record and replay can run different toolchains, so a silent algorithm change would reintroduce the exact order divergence this removes, in a form nothing would attribute correctly. SipHash-1-3 is the same algorithm std uses, so the only thing that differs from stockRandomStateis where the keys come from.Cargo.lockmoves by exactly the seven lines that dependency adds — no re-resolution, no downgrades.The intended consumer: a std-mimicking facade in the vendor
This PR ships the machinery. The call-site ergonomics it is for are a vendor-side facade over it, so a site changes its import and little else:
That is not in this PR and does not change what is. But one measured finding about it belongs here, because it bears directly on the
Defaultdecision below:.collect()will not compile against a hasher with noDefault.FromIterator for HashMap<K, V, S>requiresS: BuildHasher + Default. In hyperswitchorigin/main: 426 constructions (new/with_capacity) swap cleanly,HashMap::from(has zero uses, but 47 turbofish collects plus an unknown share of 1,521 bare.collect()calls would fail to build.So the no-
Defaultdecision has a real cost at the call site, and it is worth taking that cost deliberately rather than discovering it. RestoringDefaultis the option that reintroduces exactly the implicit path #111/#115 died on; giving the facade acollect_seeded()helper is the option that keeps this PR's guarantee and makes those sites explicit instead.Full analysis:
deja-handovers/results/collections-facade-feasibility.md.Evidence
just verifygreen. 9 new tests. Four mutations, four kills, each by a distinct test:build_hasherignores the keysthe_keys_decide_the_hash,iteration_order_follows_the_seeda_synthesized_seed_is_deterministic_and_name_sensitivea_malformed_image_fails_rather_than_redrawingdraw_keystags identicaldrawn_pairs_differ_from_each_otheriteration_order_follows_the_seedsearches a range of seeds rather than hoping one pair reorders, so it cannot pass by luck.Open question for review
Ergonomics.
HashMap::with_hasher(deja::hash_seed("name"))at each construction — acceptable, or does no-Defaultwant a helper macro? TheSeededHashMap/SeededHashSetaliases take the sting out of naming the third type parameter, but the construction site still has to be edited. This wants a vendor opinion before any large-scale adoption; nothing here forces one.Not addressed
Serialized key order. The shipped router has
serde_json'spreserve_ordertransitively, soHashMapiteration order propagates into JSON object key order. Structural comparison is unaffected, but anything computing over serialized bytes — a signature or HMAC over a response body — would change with key order. Whether that path exists here is unknown; neither this seam nor the fan-out tolerance catches it.🤖 Generated with Claude Code
https://claude.ai/code/session_019FmXkygUmueraF9oR4oqwS