P1.5.1 — Version Hash Computation — Execution Packet
Step 3 of the 5-step chain (CLAUDE.md §6).
§1. Implementation outline (single file: src/domains/rules/versioning.ts)
§1.1 Section layout
// §1. Imports
// §2. Constants
// §3. Errors
// §4. stripLocations (recursive helper)
// §5. asciiCompareByName (sort comparator — local, mirrors engine.ts:480)
// §6. canonicalizeRuleset (strip + sort + canonicalize)
// §7. computeVersionHash (canonical body || engine version → SHA-256)
// §8. verifyRuleVersion (constant-time compare)
Total target: ~250–300 lines including 100 lines of canonical JSDoc.
§1.2 Imports
import { createHash } from 'node:crypto'; // ✓ named, no `crypto.<x>`
import { canonicalize } from './canonical.js'; // P1.5.4
import type { RuleNode } from './parser.js'; // type-only, no value import
§1.3 Constants
export const ENGINE_VERSION: string = 'kappa-engine/1.0.0';
export const VERSION_HASH_PREFIX: 'sha256:' = 'sha256:';
export const VERSION_HASH_HEX_LENGTH: number = 64;
export const VERSION_HASH_TOTAL_LENGTH: number = 71; // 7 + 64
ENGINE_VERSION is bumped on any semantic change to the engine binary (per task-prompt “Common gotchas” §1). For Phase 1 initial release: kappa-engine/1.0.0.
§1.4 Error class
export class VersionHashError extends Error {
constructor(message: string) {
super(message);
this.name = 'VersionHashError';
}
}
§1.5 stripLocations impl
Recursive walker — returns a new value with every location key removed from every plain object in the graph.
export function stripLocations(value: unknown): unknown {
if (value === null) return null;
if (Array.isArray(value)) {
return value.map(stripLocations);
}
if (typeof value === 'object') {
const proto = Object.getPrototypeOf(value);
if (proto !== null && proto !== Object.prototype) {
return value; // canonicalize will reject
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (k === 'location') continue;
out[k] = stripLocations(v);
}
return out;
}
return value; // primitive (string, number, bigint, boolean, symbol, undefined)
}
Note: Object.entries on null would throw, but we already short-circuited null above. On undefined we never reach this branch (typeof === ‘undefined’, not ‘object’). Safe.
§1.6 asciiCompareByName impl
Mirrors src/domains/rules/engine.ts:480 but operates on the post-strip unknown[] (the name field survives stripping because it’s not the location key).
function asciiCompareByName(
a: { name?: unknown },
b: { name?: unknown },
): number {
const an = typeof a.name === 'string' ? a.name : '';
const bn = typeof b.name === 'string' ? b.name : '';
if (an < bn) return -1;
if (an > bn) return 1;
return 0;
}
The defensive name?: unknown accepts the post-strip shape. Real RuleNode always has name: string.
§1.7 canonicalizeRuleset impl
export function canonicalizeRuleset(
ruleset: readonly RuleNode[],
): string {
if (!Array.isArray(ruleset)) {
throw new VersionHashError('ruleset must be an array of RuleNode');
}
const stripped = stripLocations(ruleset) as unknown[];
// Defensive copy for sort (input is readonly per signature; even if not,
// we never mutate the caller's array).
const sorted = stripped.slice().sort(asciiCompareByName as (a: unknown, b: unknown) => number);
return canonicalize(sorted);
}
The cast on the comparator is safe because stripped is unknown[] after the type-erased strip walker; the comparator’s name?: unknown shape accepts any unknown value.
§1.8 computeVersionHash impl
export function computeVersionHash(
ruleset: readonly RuleNode[],
engine_version: string = ENGINE_VERSION,
): string {
if (typeof engine_version !== 'string' || engine_version.length === 0) {
throw new VersionHashError('engine_version must be a non-empty string');
}
const body = canonicalizeRuleset(ruleset); // throws on non-array / canonicalize failures
const hash = createHash('sha256');
hash.update(body, 'utf8');
hash.update('||', 'utf8');
hash.update(engine_version, 'utf8');
return VERSION_HASH_PREFIX + hash.digest('hex');
}
§1.9 verifyRuleVersion impl
export function verifyRuleVersion(
expected: string,
actual: string,
): boolean {
if (typeof expected !== 'string' || typeof actual !== 'string') {
return false;
}
const expLen = expected.length;
const actLen = actual.length;
// Empty-input short-circuit (i % 0 is NaN; we'd never enter the loop anyway).
if (expLen === 0 && actLen === 0) {
return true;
}
if (expLen === 0 || actLen === 0) {
return false;
}
// Accumulate length mismatch + per-byte XOR.
let acc = expLen ^ actLen;
const scanLen = expLen > actLen ? expLen : actLen;
for (let i = 0; i < scanLen; i = i + 1) {
const e = expected.charCodeAt(i % expLen);
const a = actual.charCodeAt(i % actLen);
acc = acc | (e ^ a);
}
return acc === 0;
}
The i % 0 problem is sidestepped by the explicit length-zero guards above.
expected.charCodeAt(i) returns NaN for i >= length — using i % expLen keeps the index in range. Since we already verified both lengths are nonzero, % expLen and % actLen are well-defined.
§2. File structure
src/domains/rules/versioning.ts ~270 lines
src/__tests__/domains/rules/versioning.test.ts ~400 lines
§3. Test plan (versioning.test.ts)
§3.1 G1 — output format
result.startsWith('sha256:')is trueresult.length === 71- The 64 hex tail matches
/^[0-9a-f]{64}$/ - Hash of empty array is well-formed
- Hash of single-rule array is well-formed
§3.2 G2 — order independence (Fixture 1) — LOAD-BEARING
Build TWO rulesets:
rsA = [ruleAlpha, ruleBeta, ruleGamma]rsB = [ruleGamma, ruleBeta, ruleAlpha]
where each rule has the same name, guards, effects, but different declaration positions (different location).
expect(computeVersionHash(rsA)).toBe(computeVersionHash(rsB));
§3.3 G3 — content sensitivity (Fixture 2)
Two rulesets identical except for one character in a guard:
rsBase—guard.condition.value = 100nrsMutated—guard.condition.value = 101n
expect(computeVersionHash(rsBase)).not.toBe(computeVersionHash(rsMutated));
Three sub-cases: int-literal change, string-literal change, function name change.
§3.4 G4 — engine version sensitivity (Fixture 3)
const rs = [/* one rule */];
expect(computeVersionHash(rs, 'v1')).not.toBe(computeVersionHash(rs, 'v2'));
expect(computeVersionHash(rs, 'kappa-engine/1.0.0'))
.toBe(computeVersionHash(rs)); // default = v1.0.0
§3.5 G5 — location independence
Two textually-identical rules in different file positions. The location field differs in EVERY field (startLine, startColumn, endLine, endColumn). Hashes must match.
§3.6 G6 — empty ruleset
expect(computeVersionHash([])).toMatch(/^sha256:[0-9a-f]{64}$/);
expect(computeVersionHash([])).toBe(computeVersionHash([])); // determinism
§3.7 G7 — constant-time compare correctness (Fixture 4)
Note: timing-channel verification can’t be made deterministic in a test (host scheduling), so we verify FUNCTIONAL correctness:
- Identical strings → true
- Single-byte difference at the START → false (would early-exit a non-CT impl)
- Single-byte difference at the END → false (would late-exit a non-CT impl)
- Length mismatch (
'sha256:abc'vs'sha256:abcd') → false - Empty equals empty → true
- One empty → false
- Non-string inputs → false
§3.8 G8 — verifyRuleVersion ground truth
Round-trip: computeVersionHash(rs) then verifyRuleVersion(h, computeVersionHash(rs)) → true.
§3.9 G9 — error model
computeVersionHash(null as any, 'v')→ throwsVersionHashErrorcomputeVersionHash([], '')→ throwsVersionHashErrorcomputeVersionHash([], 5 as any)→ throwsVersionHashErrorcomputeVersionHash([{ /* node with cycle */ }], 'v')→ throwsCanonicalSerializationError(from canonicalize, not wrapped)
§3.10 G10 — stripLocations + canonicalizeRuleset (exported for tests)
stripLocations(null)→nullstripLocations(undefined)→undefinedstripLocations(42)→42stripLocations(42n)→42nstripLocations('s')→'s'stripLocations(true)→truestripLocations({ location: { startLine: 1 }, x: 5 })→{ x: 5 }stripLocations({ location: { startLine: 1 }, nested: { location: { startLine: 2 }, y: 6 } })→{ nested: { y: 6 } }stripLocations([{ location: {}, x: 1 }, { location: {}, x: 2 }])→[{ x: 1 }, { x: 2 }]canonicalizeRuleset([])returns'[]'(canonicalize of empty array)canonicalizeRulesetof two-rule unsorted → returns same string as the sorted input- Non-plain object passes through (Map):
stripLocations(new Map())returns the Map unchanged - Original input is NOT mutated (call stripLocations on a frozen object → no throw, returns a new object)
§3.11 Fixture builder
A helper that builds a tiny RuleNode by name + a single int-literal guard:
function makeRule(
name: string,
guardValue: bigint,
loc?: Partial<Location>,
): RuleNode {
const fullLoc: Location = {
startLine: loc?.startLine ?? 1,
startColumn: loc?.startColumn ?? 1,
endLine: loc?.endLine ?? 1,
endColumn: loc?.endColumn ?? 10,
};
return {
type: 'RuleNode',
location: fullLoc,
name,
guards: [{
type: 'GuardClause',
location: fullLoc,
action: 'admit',
reason: null,
condition: {
type: 'IntLiteral',
location: fullLoc,
value: guardValue,
},
}],
effects: [],
};
}
Used to keep test setup terse and to make the order/location-independence fixtures self-evidently sound.
§4. Build / lint / test gate
Run from worktree root:
cd .worktrees/claude/p1-5-1-version-hash
npm run build && npm run lint && npm test
All three are required gates per CLAUDE.md §5. The npm test invocation will exercise:
versioning.test.ts— this task’s tests (~50–60 cases)determinism.test.ts §Group 12— corpus self-scan overversioning.ts(must not matchcrypto.*regex)- All existing 1658 tests (no regressions)
§5. Commit sequence
audit(p1-5-1-version-hash): inventory surface [done — dbd3b87c]
contract(p1-5-1-version-hash): behavioral contract [done — f3ba9d5f]
packet(p1-5-1-version-hash): execution plan [this commit]
feat(p1-5-1-version-hash): deterministic ruleset version hash
verify(p1-5-1-version-hash): test evidence
§6. Risk register (delta from audit §11)
| Risk | Mitigation |
|---|---|
Sort comparator’s lambda receives unknown post-strip |
Cast through helper that accepts { name?: unknown } shape |
Object.entries(null) |
Short-circuit null first in stripLocations |
i % 0 in constant-time compare |
Explicit length-zero guards before the loop |
charCodeAt(i) returns NaN |
i % len is bounded; both lengths verified non-zero before loop |
Array.prototype.slice on readonly RuleNode[] may TS-error |
.slice() works on readonly arrays since ES2015 (returns mutable copy) |
| Test fixture for cycle detection might inadvertently introduce cycle in test runner | Build cycle inside it() body, never at module top level |
Canonicalize throwing CanonicalSerializationError from inside computeVersionHash → caller may want a re-wrapped error |
We deliberately re-throw without wrapping — the contract §4 documents this |
§7. Out of scope
wireVersionHash(registry)(registry doesn’t exist on base SHA; would touch sibling P1.2.4)- Multi-hash-algorithm support (sha256 only per AC8)
Uint8Arraydigest variant (hex output only per AC3)- Persistence to DB (the version hash is stored downstream in event metadata, not by this module)
- π governance parity-run hooks (P1.5.5 / P1.5.2 task)
§8. Definition of done
versioning.tsexists with the public surface from contract §2versioning.test.tsexists and exercises every G1–G10 groupnpm run buildpassesnpm run lintpassesnpm testpasses — all 1658+ tests including new versioning suite + corpus self-scan- Fixture 1 (order independence) PASSES — the load-bearing test
- Five-step chain commits land on
feature/p1-5-1-version-hash - PR opened against
mainwith the verification doc as evidence