Repository navigation
fix(spec): content hashes sort by code unit, never by locale collation - #436
Merged
Merged
Conversation
computeMapSnapshotHash (registry.ts), computeWorkSchemaHash (work-schema.ts) and computeJobSpecHash (job-spec.ts) sorted with localeCompare, which uses ICU collation. For kernel ids with - _ . : and for mixed-case addresses, ICU order differs from code-unit order, so a Python, Go or Solidity implementation, or the next ICU release, would compute a different hash for the same content (oracle #3348, #3350). All three now sort with compareCodeUnits (util/code-unit-order.ts), the order canonicalize gives object keys. Vectors with punctuation ids and mixed-case buyers are in util/code-unit-order.vectors.json; a Python recomputation matches all three, and the ICU-order map hash is pinned as a negative. No consumer outside spec, and no pinned golden, depends on the old order. spec 802/802, tsc clean. Each call site reverted to localeCompare fails its vector (4 mutants). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu
added a commit
that referenced
this pull request
Sep 29, 2026
…tered_key computeMapSnapshotHash now sorts keys by UTF-16 code unit, never locale collation, so a non-JS publisher of the kernel signing-key registry computes the same snapshotHash (oracle #3348). #416's pinned vector 0x38ff407c..28db is unchanged: kernel-a and kernel-b sort the same way under both orders. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lation (E1 finding 1) fixer-echo1: reproduced on bd048b2 (Node 22.23.2, ICU 78.2). At equal ticks, compareTimestamps("r_a", "r-a") returned -1 while a code-unit mirror returns +1, so both merge orders picked "quarantined" where a Go/Python/Rust replica picks "trusted". localeCompare also returned 0 for distinct ids (U+00E9 vs e+U+0301, "r"+U+0000 vs "r", "r"+U+200B vs "r"), so lwwRegisterMerge(a, b) kept "trusted" and lwwRegisterMerge(b, a) kept "quarantined": two replicas merging the same states in different orders diverged. compareTimestamps now breaks ties with compareCodeUnits from @pcc/spec (federation already depends on it). Distinct ids never tie, and the U+FFFF "kill" sentinel still beats any real id at an equal tick. New tests: 3 in lww-register.test.ts (2 failed before the fix). federation 131/131, tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n (E1 finding 2) fixer-echo1: reproduced on bd048b2 (Node 22.23.2, ICU 78.2). For two priority-1 endpoints ending "x_a" and "x-a", canonicalIdentityJson put x_a first while a code-unit mirror puts x-a first, so the documented hashing/signing preimage differed from a non-JS implementation. For URLs ICU calls equal (".../U+00E9" vs ".../e+U+0301") localeCompare returned 0 and the canonical JSON followed the input order (two input orders, two strings). sortedEndpoints now breaks priority ties with compareCodeUnits from @pcc/spec (dht-core already depends on it). New tests: 2 in identity.test.ts (both failed before the fix). dht-core 39/39, tsc clean; federation 131/131. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fixer-echo1: reproduced on bd048b2. The same buyer twice with amounts [1,2] hashed to 0x9fdcb017...dfd8 and with [2,1] to 0x29770397...2474, because the comparator only looked at buyer and the stable sort kept input order. A sealed job whose cofundedBy list was reordered then failed assertJobSpecIsWellFormed with "jobSpecHash mismatch". computeJobSpecHash now breaks buyer ties with the code-unit order of each entry's canonical JSON, so any permutation hashes the same. Duplicate buyers stay valid (no new rejection). Hashes of lists with distinct buyers are unchanged (the existing jobSpec vector still reproduces). New vector jobSpecDuplicateBuyers (4 entries, pins "10 sorts before 2"); an independent Python mirror (utf-16-be surrogatepass keys) reproduces its hash 0xe8424ee8...6867 and the existing jobSpec vector. New tests: 3 in content-hash-order.test.ts (2 failed before the fix). spec 805/805, tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…E1 finding 4) fixer-echo1: reproduced on bd048b2 (Node 22.23.2, Unicode 17.0). JavaScript lowercases U+0130 to two code units (U+0069 U+0307), so the distinct keys U+0130 and i+U+0307 collide: both set snapshots hash to 0x01b19a0f...6fcd and the map throws "duplicate key". On the same host, U+A7CB (added in Unicode 16.0) lowercases to U+0264 in Node but is unchanged in Python 3.12 (Unicode 15.0): the set snapshot of [U+A7CB] hashes to 0x50faa1c7...3232 in Node and 0xb58df978...2000 in a Python mirror, while an ASCII set hashes the same in both (0xc9c00f3c...b8a2). computeSetSnapshotHash and computeMapSnapshotHash now refuse any entry or key outside printable ASCII (0x21-0x7e), before lowercasing, with a thrown Error like the existing duplicate-key refusal; SetEntrySchema and MapEntrySchema.key carry the same regex. Checked first: every existing vector and test key is printable ASCII, and there is no non-test caller of either function (lamasu/master ac86a40 and this branch). New tests: 3 in content-hash-order.test.ts (2 failed before the fix). The refused list includes U+212A (Kelvin sign, lowercases to ASCII "k"), which kills a validate-after-lowercasing mutant. spec 808/808, tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tepass recipe (E1 finding 6) fixer-echo1: reproduced on bd048b2. The test's "independent" comparator was UTF-8 byte order: for U+E000 vs U+10000 compareCodeUnits returned 1 and it returned -1, and it returned 0 for distinct lone surrogates (U+D800 vs U+DC00, U+DFFF vs U+FFFD) because UTF-8 encodes each as U+FFFD. Every vector was ASCII, so nothing caught this. The documented Python recipe s.encode("utf-16-be") raised UnicodeEncodeError on a lone surrogate. - vectors: new codeUnitOrder vector (11 strings: ASCII, U+00E9, U+E000, U+FFFD, U+FFFF, U+10000, U+10FFFF, lone D800/DC00/DFFF) with its sorted order and UTF-16BE bytes; the top-level rule no longer says byte order. - test: the reference comparator is element-wise charCodeAt order; new checks that canonicalize orders these keys the same way and that UTF-8 and code-point order differ. With the old UTF-8 reference the new vector test failed (1 failed | 14 passed). - code-unit-order.ts: the Python recipe is key=lambda s: s.encode("utf-16-be", "surrogatepass"). A Python recomputation with that recipe reproduces every vector value (codeUnitOrder, mapSnapshot incl. negative, workSchema, jobSpec, jobSpecDuplicateBuyers); the old recipe raises on codeUnitOrder. spec 812/812, tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…casing (review E1) Follow-up to E1 finding 4, found while it was being fixed and reproduced here. With registry keys now printable ASCII, setContains and mapGet still lowercased the LOOKUP value with full Unicode rules. U+212A KELVIN SIGN lowercases to ASCII "k", so "Kernel-1" matched the member "kernel-1": setContains returned true and mapGet returned its value (two tests failing before the fix). Both now treat any candidate outside ^[\x21-\x7e]+$ as absent, before lowercasing. spec 814/814; tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he host's collation (review E1) Review E1 (requested check 4) asked for an explicitly deterministic comparator in VerifierSelector's not-enough-nodes fallback, which sorted seeded sha256 digests with localeCompare. Reproduced: localeCompare without a locale follows the host LANG, and Danish collation reads "aa" as "å", after "z". For the seed in the tests, node-13's digest begins "aa" and node-6's "ed". The new test passes under en_US and FAILS under da_DK at the old code. It now uses compareCodeUnits, and passes under both. verifier 586/586 (en_US; the selector file also 14/14 under da_DK); tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…II ids (review E1b) Cross-family review E1b on 8a94121, DO-NOT-SHIP. - Finding 1 (HIGH): only the not-enough-nodes fallback canonicalized order; the weighted path walked the pool in its input order. - Finding 2 (MEDIUM): exclusions and node ids went through Unicode toLowerCase, so U+212A (the Kelvin sign) excluded the distinct id "k-1". Reproduced at 8a94121 first (4 failing tests): - the five-node fixture, count 3, SEED: [v3,v5,v1] for the pool, but [v2,v1,v5] for the reversed pool (the reviewer's trace); - a pool with duplicate ids was accepted; - a Kelvin-sign exclusion was accepted and folded onto "k-1"; - a node whose id is not printable ASCII (or empty) was eligible. Now: ids and exclusions are 1-128 printable ASCII and compare after ASCII-only case folding. Eligible nodes are put in canonical order (folded id, code units) before weights are computed, so any permutation of the pool chooses the same verifiers. A duplicate id (ASCII case folded) refuses the pool; a non-ASCII exclusion refuses the request; a node with a non-ASCII id is never eligible. verifier 590/590, tsc clean; 4 mutants (each check removed) killed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…r (review E1b) Cross-family review E1b on 8a94121, finding 3 (MEDIUM): sortedEndpoints compared only priority and URL, so two endpoints differing only in transport kept their input order, and canonicalIdentityJson's bytes changed with it. Lane-found, same class: each endpoint was serialized in its own key order, so {transport,url,priority} and {priority,url,transport} gave different preimages. Reproduced at 8a94121 first: both tests failed (the reversed pair, and the reordered keys, each produced different canonicalIdentityJson bytes). Now the sort breaks a priority-and-URL tie by transport (code units), and the preimage rebuilds each endpoint as {transport, url, priority}, the declared fields in a fixed order. Bytes for endpoints already written in that order are unchanged. dht-core 41/41, tsc clean; 2 mutants killed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
Cross-family review E1c on 7621965: SHIP-WITH-FIXES, one MEDIUM. A NaN priority made sortedEndpoints' comparator answer NaN (treated as equal), so endpoints kept their input order and canonicalIdentityJson's bytes changed with it; JSON also writes non-finite numbers as null. Reproduced at 7621965 first: the new test failed ("NaN: expected [Function] to throw"), i.e. NaN, Infinity and -Infinity were accepted. sortedEndpoints now refuses any priority that is not a finite number (RangeError) before sorting, so canonicalIdentityJson and preferredEndpoint refuse it too. The test covers NaN, Infinity, -Infinity, and the reviewer's reversed NaN pair. No other package calls these helpers. dht-core 42/42, tsc clean; removing the check fails the test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
LamaSu
marked this pull request as ready for review
October 1, 2026 18:48
LamaSu
added a commit
that referenced
this pull request
Oct 3, 2026
#360 was SHIP at c73d708 (EC1c) with full CI green, but master moved on 9/30–10/01 (#424, #459 N80, #436, #338, #477, #483 and others), and three files conflicted. Resolutions: - packages/spec/src/index.ts: both sides added exports at the same place. Both are kept: #360's `economics` namespace, and master's money-status and readmodels exports. - packages/gateway/src/routes/swf.ts: both sides made POST /api/swf/epochs/:epochId/distribute answer 501 not_available before anything is read. Master's hunk (#421's, reviewed and merged with #424) is kept. The behavior is identical. - packages/gateway/src/routes/rewards.ts: #360 made the reward, certificate and treasury routes answer 501 and deleted their fixtures. Master's N80 (#459, reviewed and merged) changed only the certificates: an empty list, 404 for one, and a 501 mint that claims nothing. It kept the reward and treasury fixtures. Kept: master's N80 certificates, and #360's 501s for rewards and treasury, with no fixture import. economics-no-fabrication.test.ts drops its three certificate rows (certificates-n80.test.ts pins N80), and the reward and treasury rows still assert 501. On the merged tree: spec 1286/1286; contracts 267/267; mcp-server 76/76; gateway 3717 passed, 13 skipped, 0 failed; dashboard 353/353; tsc clean for contracts, mcp-server, gateway and dashboard. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu
added a commit
that referenced
this pull request
Oct 3, 2026
…r master moved #359 (N15, canonicalize refuses non-JSON) was SHIP at aa01536. It went CONFLICTING once master merged #436, #477, #483, #447 and #367, and it dropped off MERGE NOW. The only conflicts were import lists: - packages/kernel-sdk/src/job-handler.ts: #359's canonicalSnapshot plus master's LO-EV-1 imports (parseEd25519SignatureHex, sessionKeyDelegationPreimage, signingPreimage); - packages/kernel/src/digital/accounting-kernel.ts: canonicalSnapshot plus signingPreimage. The code on both sides is unchanged. The suites check master's newer code under #359's stricter canonicalize. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
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
Three content hashes in
packages/specsorted their inputs withString.prototype.localeCompare, which orders by ICU collation, not by code unit:computeMapSnapshotHash(types/registry.ts), the registry-snapshot hash that feat(spec): kernel signing-key registry snapshot + ident.registered_key PrimitiveVerifier (N19) #416'sident.registered_keyverifier pins;computeWorkSchemaHash(types/work-schema.ts, event types by id, work products by kind);computeJobSpecHash(types/job-spec.ts, co-funders by buyer).They now sort with
compareCodeUnits(util/code-unit-order.ts), the ordercanonicalizealready gives object keys.Why
ICU collation is not code-unit order (oracle, bus #3348 and #3350):
_before-,:and., and before digits.On Node 22 / ICU 78 the kernel ids
[kernel_a, kernel-1, kernel-a, kernel:a, kernel.a, kernela]sort in that order. Code units, and Python'ssorted, give[kernel-1, kernel-a, kernel.a, kernel:a, kernel_a, kernela].So a Python, Go or Solidity publisher or verifier, or the next ICU release, computes a different hash for the same content, and the pinned hash stops matching. The oracle holds its
ident.registered_keymirror until this rule is pinned.Vectors
packages/spec/src/util/code-unit-order.vectors.json:0xfcb26a94…14ceover the six punctuation kernel ids. The ICU-order hash0x55dd2ccc…5b94is pinned as a negative;cmm_2/cmm2/cmm-1andphoto_set/photo2/photo-1;0xab…,0xAB…,0xAb….An independent Python recomputation (sorted-key compact JSON over byte-sorted entries) reproduces all three hashes. A mirror in another language must sort by UTF-16 code units. For ASCII that is byte order; beyond the BMP, a Python mirror sorts with
key=lambda s: s.encode("utf-16-be").Safety
packages/speccalls these three functions on master, and no test pins their old values.computeSetSnapshotHashalready used code-unit.sort()and is unchanged.localeComparecalls in the repo sort display lists, not hashed content.Tests (DGX Spark)
content-hash-order.test.ts;tscis clean.localeComparefails its vector.Draft. Merging is the operator's call. #416 merges this before the oracle mirrors it.
🤖 Generated with Claude Code