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 true
  • result.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:

  • rsBaseguard.condition.value = 100n
  • rsMutatedguard.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') → throws VersionHashError
  • computeVersionHash([], '') → throws VersionHashError
  • computeVersionHash([], 5 as any) → throws VersionHashError
  • computeVersionHash([{ /* node with cycle */ }], 'v') → throws CanonicalSerializationError (from canonicalize, not wrapped)

§3.10 G10 — stripLocations + canonicalizeRuleset (exported for tests)

  • stripLocations(null)null
  • stripLocations(undefined)undefined
  • stripLocations(42)42
  • stripLocations(42n)42n
  • stripLocations('s')'s'
  • stripLocations(true)true
  • stripLocations({ 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)
  • canonicalizeRuleset of 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 over versioning.ts (must not match crypto.* 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)
  • Uint8Array digest 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.ts exists with the public surface from contract §2
  • versioning.test.ts exists and exercises every G1–G10 group
  • npm run build passes
  • npm run lint passes
  • npm test passes — 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 main with the verification doc as evidence


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.