P1.2.4 — κ Rule Loader / Registry — Execution Packet

Step 3 of the 5-step executor chain. Builds on docs/audits/p1-2-4-registry-audit.md and docs/contracts/p1-2-4-registry-contract.md. Gates implementation (Step 4).

§P1. Files

Path Action Approx LOC
src/domains/rules/registry.ts CREATE ~330
src/__tests__/domains/rules/registry.test.ts CREATE ~480
docs/audits/p1-2-4-registry-audit.md created in Step 1
docs/contracts/p1-2-4-registry-contract.md created in Step 2
docs/packets/p1-2-4-registry-packet.md this file
docs/verification/p1-2-4-registry-verification.md created in Step 5

No other files touched. Worktree-scope discipline per audit §11.

§P2. Implementation outline — registry.ts

§P2.1. File header (JSDoc)

Multi-line module docstring covering:

  • Phase 1 κ Rule Engine — Rule Loader / Registry (P1.2.4).
  • Pure synchronous module — no I/O, no DB, no env reads, no console.
  • Builds on P1.2.2 parser + P1.2.3 validator + P1.3.1 engine; imports parse, validate, and AST/result types.
  • Rule indexing strategy: prefix convention (audit §6.1); 13 TransitionTypes per extraction §7; 3 active categories per CATEGORY_BY_TRANSITION_TYPE.
  • Public surface lock per contract §2: enum, constants, 3 error classes, RuleRegistry class.
  • Determinism corpus self-scan compatibility — no Math., no Date., no timers, no network, no fs imports, no crypto.*, no process timing, no await/async, no float literals.
  • Reference list: audit, contract, rule-engine.md, extraction §7 + §8, s11-rule-engine.md, s12-dsl.md.

§P2.2. Imports

import { parse } from './parser.js';
import type { ParseError, RuleNode, Expression } from './parser.js';
import { validate } from './validator.js';
import type { ValidationError } from './validator.js';
import type { Category, CategorizedRule, RuleRegistry as IRuleRegistry } from './engine.js';

parse and validate are runtime imports; everything else is type-only. The IRuleRegistry rename avoids a name clash with the local class.

§P2.3. Public surface — TransitionType + constants

export type TransitionType =
  | 'COMMITMENT_CREATE' | 'COMMITMENT_ACCEPT'
  | 'SETTLEMENT_COMPLETE' | 'SETTLEMENT_FAIL'
  | 'DISPUTE_OPEN' | 'DISPUTE_RESOLVE'
  | 'GOVERNANCE_PROPOSE' | 'GOVERNANCE_VOTE'
  | 'IDENTITY_CREATE' | 'IDENTITY_UPDATE'
  | 'FORK_CREATE' | 'FORK_MERGE'
  | 'REPUTATION_DECAY';

export const TRANSITION_TYPES: readonly TransitionType[] = Object.freeze([
  'COMMITMENT_CREATE',
  'COMMITMENT_ACCEPT',
  'SETTLEMENT_COMPLETE',
  'SETTLEMENT_FAIL',
  'DISPUTE_OPEN',
  'DISPUTE_RESOLVE',
  'GOVERNANCE_PROPOSE',
  'GOVERNANCE_VOTE',
  'IDENTITY_CREATE',
  'IDENTITY_UPDATE',
  'FORK_CREATE',
  'FORK_MERGE',
  'REPUTATION_DECAY',
]);

export const CATEGORY_BY_TRANSITION_TYPE: Readonly<Record<TransitionType, Category>> =
  Object.freeze({
    COMMITMENT_CREATE: 'Admission',
    COMMITMENT_ACCEPT: 'Admission',
    DISPUTE_OPEN: 'Admission',
    GOVERNANCE_PROPOSE: 'Admission',
    IDENTITY_CREATE: 'Admission',
    FORK_CREATE: 'Admission',
    SETTLEMENT_COMPLETE: 'StateTransition',
    SETTLEMENT_FAIL: 'StateTransition',
    DISPUTE_RESOLVE: 'StateTransition',
    GOVERNANCE_VOTE: 'StateTransition',
    IDENTITY_UPDATE: 'StateTransition',
    FORK_MERGE: 'StateTransition',
    REPUTATION_DECAY: 'Consequence',
  });

export const DEFAULT_CATEGORY: Category = 'StateTransition';

§P2.4. Public surface — error classes

export class RulesetParseError extends Error {
  override readonly name = 'RulesetParseError';
  readonly errors: readonly ParseError[];
  constructor(errors: readonly ParseError[]) {
    super(`Ruleset parse failed (${errors.length} error(s))`);
    this.errors = Object.freeze([...errors]);
  }
}

export class RulesetValidationError extends Error {
  override readonly name = 'RulesetValidationError';
  readonly errors: readonly ValidationError[];
  constructor(errors: readonly ValidationError[]) {
    super(`Ruleset validation failed (${errors.length} error(s))`);
    this.errors = Object.freeze([...errors]);
  }
}

export class AmbiguousRulesetError extends Error {
  override readonly name = 'AmbiguousRulesetError';
  readonly rule1_name: string;
  readonly rule2_name: string;
  readonly specificity: number;
  readonly transition_type: TransitionType | null;
  constructor(
    rule1_name: string,
    rule2_name: string,
    specificity: number,
    transition_type: TransitionType | null,
  ) {
    const ttDisplay = transition_type === null ? '<none>' : transition_type;
    const reason =
      specificity < 0
        ? `duplicate rule name '${rule1_name}'`
        : `tied specificity=${specificity} for transition_type=${ttDisplay} between '${rule1_name}' and '${rule2_name}'`;
    super(`Ambiguous ruleset: ${reason}`);
    this.rule1_name = rule1_name;
    this.rule2_name = rule2_name;
    this.specificity = specificity;
    this.transition_type = transition_type;
  }
}

The specificity: -1 sentinel for the duplicate-name case is documented in contract §4.

§P2.5. The RuleRegistry class skeleton

interface InternalRule {
  rule: RuleNode;
  category: Category;
  transitionType: TransitionType | null;
  specificity: number;
  declarationIndex: number;
}

const EMPTY_RULES: readonly RuleNode[] = Object.freeze([]);

export class RuleRegistry implements IRuleRegistry {
  readonly size: number;
  private readonly allRules: readonly CategorizedRule[];
  private readonly byName: Map<string, RuleNode>;
  private readonly byType: Map<TransitionType, readonly RuleNode[]>;

  private constructor(
    allRules: readonly CategorizedRule[],
    byName: Map<string, RuleNode>,
    byType: Map<TransitionType, readonly RuleNode[]>,
  ) {
    this.allRules = allRules;
    this.byName = byName;
    this.byType = byType;
    this.size = allRules.length;
    Object.freeze(this);
  }

  static loadRuleset(source: string): RuleRegistry {
    // ... see §P2.6
  }

  getAll(): readonly CategorizedRule[] {
    return this.allRules;
  }

  getRule(name: string): RuleNode | null {
    const r = this.byName.get(name);
    return r === undefined ? null : r;
  }

  getByTransitionType(type: TransitionType): readonly RuleNode[] {
    const slice = this.byType.get(type);
    return slice === undefined ? EMPTY_RULES : slice;
  }

  /**
   * Phase 1 stub. Returns 'sha256:stub:<size>n'. P1.5.1 will replace this
   * body with a real SHA-256 hash over canonicalize(ruleset) || engine_version.
   *
   * TODO(P1.5.1): replace with `wireVersionHash(this).computeVersionHash()`.
   */
  computeVersionHash(): string {
    return 'sha256:stub:' + this.size + 'n';
  }
}

§P2.6. The loadRuleset algorithm — body

static loadRuleset(source: string): RuleRegistry {
  // Stage 1 — Parse.
  const parsed = parse(source);
  if (parsed.errors.length > 0) {
    throw new RulesetParseError(parsed.errors);
  }

  // Stage 2 — Validate every rule, aggregating across all rules.
  const validationErrors: ValidationError[] = [];
  for (const rule of parsed.ast) {
    const result = validate(rule);
    if (!result.valid) {
      for (const e of result.errors) {
        validationErrors.push(e);
      }
    }
  }
  if (validationErrors.length > 0) {
    throw new RulesetValidationError(validationErrors);
  }

  // Stage 3 + 4 — Compute per-rule metadata.
  const internals: InternalRule[] = [];
  for (let i = 0; i < parsed.ast.length; i += 1) {
    const rule = parsed.ast[i]!;
    const transitionType = matchTransitionTypePrefix(rule.name);
    const category =
      transitionType === null
        ? DEFAULT_CATEGORY
        : CATEGORY_BY_TRANSITION_TYPE[transitionType];
    const specificity = computeSpecificity(rule);
    internals.push({
      rule,
      category,
      transitionType,
      specificity,
      declarationIndex: i,
    });
  }

  // Stage 5a — Sort: specificity desc, then declaration order asc.
  internals.sort((a, b) => {
    if (b.specificity !== a.specificity) {
      return b.specificity - a.specificity;
    }
    return a.declarationIndex - b.declarationIndex;
  });

  // Stage 5b — Detect duplicate names (independent of specificity).
  const seenNames = new Map<string, string>(); // name -> first rule's name (== same)
  for (const r of internals) {
    if (seenNames.has(r.rule.name)) {
      throw new AmbiguousRulesetError(
        r.rule.name,
        r.rule.name,
        -1,
        null,
      );
    }
    seenNames.set(r.rule.name, r.rule.name);
  }

  // Stage 5c — Detect specificity ties on shared non-null transition types.
  for (let i = 1; i < internals.length; i += 1) {
    const prev = internals[i - 1]!;
    const curr = internals[i]!;
    if (
      prev.specificity === curr.specificity &&
      prev.transitionType !== null &&
      prev.transitionType === curr.transitionType
    ) {
      throw new AmbiguousRulesetError(
        prev.rule.name,
        curr.rule.name,
        prev.specificity,
        prev.transitionType,
      );
    }
  }

  // Stage 5d — Build name + type indexes; emit immutable CategorizedRule[].
  const allRules: CategorizedRule[] = [];
  const byName = new Map<string, RuleNode>();
  const byTypeMutable = new Map<TransitionType, RuleNode[]>();
  for (const r of internals) {
    allRules.push({ rule: r.rule, category: r.category });
    byName.set(r.rule.name, r.rule);
    if (r.transitionType !== null) {
      let bucket = byTypeMutable.get(r.transitionType);
      if (bucket === undefined) {
        bucket = [];
        byTypeMutable.set(r.transitionType, bucket);
      }
      bucket.push(r.rule);
    }
  }
  Object.freeze(allRules);
  const byType = new Map<TransitionType, readonly RuleNode[]>();
  for (const [t, bucket] of byTypeMutable) {
    Object.freeze(bucket);
    byType.set(t, bucket);
  }
  return new RuleRegistry(allRules, byName, byType);
}

§P2.7. computeSpecificity — pure helper

function computeSpecificity(rule: RuleNode): number {
  let sum = 0;
  for (const guard of rule.guards) {
    sum += topLevelTerms(guard.condition);
  }
  return sum;
}

function topLevelTerms(condition: Expression | null): number {
  if (condition === null) {
    return 0;
  }
  if (condition.type === 'LogicalOp' && condition.op === 'and') {
    let total = 0;
    for (const operand of condition.operands) {
      total += topLevelTerms(operand);
    }
    return total;
  }
  return 1;
}

The recursion descends only through LogicalOp(op='and') chains. All other expression types stop recursion at depth 0 — they count as 1 term regardless of internal structure. This satisfies the “Count only top-level operands, not nested sub-expressions” gotcha.

§P2.8. matchTransitionTypePrefix — pure helper

function matchTransitionTypePrefix(name: string): TransitionType | null {
  for (const type of TRANSITION_TYPES) {
    if (name === type) {
      // Bare prefix with no remainder — treat as no match.
      continue;
    }
    const sep = type + '_';
    if (name.length > sep.length && name.startsWith(sep)) {
      return type;
    }
  }
  return null;
}

The check name.length > sep.length ensures at least one character of remainder follows the underscore. The order of TRANSITION_TYPES is irrelevant for correctness (no enum value is a _-bounded prefix of another). The function is locale-free (startsWith is ECMAScript-defined).

§P3. Test plan — registry.test.ts

§P3.1. Imports + helpers

import {
  RuleRegistry,
  TRANSITION_TYPES,
  CATEGORY_BY_TRANSITION_TYPE,
  DEFAULT_CATEGORY,
  AmbiguousRulesetError,
  RulesetParseError,
  RulesetValidationError,
} from '../../../domains/rules/registry.js';
import type { TransitionType } from '../../../domains/rules/registry.js';
import type { RuleNode } from '../../../domains/rules/parser.js';
import type { Category } from '../../../domains/rules/engine.js';

Helpers:

  • mkRule(name, guardBody) → returns a κ source for rule <name> { guards { <guardBody> -> admit } effects { } }
  • concat(...sources) → joins source strings with \n\n
  • expectThrowsRulesetParse(fn) → asserts thrown error is RulesetParseError + returns its .errors
  • (similar for the other two error classes)

§P3.2. F1 — TransitionType enum + constants

  • TRANSITION_TYPES.length === 13
  • Every value in TRANSITION_TYPES is a key in CATEGORY_BY_TRANSITION_TYPE
  • Every value of CATEGORY_BY_TRANSITION_TYPE is one of 'Admission' | 'StateTransition' | 'Consequence' | 'Promotion'
  • DEFAULT_CATEGORY === 'StateTransition'
  • The 6 Admission entries match the contract table
  • The 6 StateTransition entries match the contract table
  • REPUTATION_DECAY → Consequence

§P3.3. F2 — loadRuleset happy path

  • Three rules: COMMITMENT_ACCEPT_FastTrack, SETTLEMENT_COMPLETE_StandardPayout, Yield (no prefix)
  • All three with valid in-scope guards (e.g. $event.kind == 1 -> admit)
  • Registry size === 3
  • getRule('Yield') returns the third RuleNode
  • getRule('Unknown') returns null
  • getByTransitionType('COMMITMENT_ACCEPT') returns 1 rule (FastTrack); getByTransitionType('REPUTATION_DECAY') returns 0 rules (empty array)
  • getAll() returns 3 entries; categories match the prefix mapping

§P3.4. F3 — loadRuleset parse-error aggregation

  • Source containing a lex error (e.g. a stray ~) AND a parse error (e.g. unterminated rule)
  • loadRuleset(...) throws RulesetParseError
  • error.errors.length >= 1
  • error.message contains Ruleset parse failed

§P3.5. F4 — loadRuleset validator-error aggregation

  • Three rules, two with validator violations (e.g. one calls now(); another references $bogus.x); one valid
  • loadRuleset(...) throws RulesetValidationError
  • error.errors.length >= 2 (errors from BOTH bad rules surface, not just the first)
  • Verifies “no short-circuit” semantics

§P3.6. F5 — Specificity sort

  • Rule A: $event.a > 0 and $event.b > 0 and $event.c > 0 -> admit (3 top-level terms)
  • Rule B: $event.a > 0 -> admit (1 term)
  • Source declares B BEFORE A
  • After load, getAll()[0].rule.name === 'A' (higher specificity wins despite later declaration)
  • getAll()[1].rule.name === 'B'

Ties on declaration order:

  • Rule X: $event.a > 0 -> admit (1 term)
  • Rule Y: $event.b > 0 -> admit (1 term)
  • X declared first, Y second
  • After load, getAll()[0].rule.name === 'X'

§P3.7. F6 — Specificity tie detection

  • Two COMMITMENT_ACCEPT_* rules with identical specificity (each 1 term)
  • loadRuleset(...) throws AmbiguousRulesetError
  • error.transition_type === 'COMMITMENT_ACCEPT'
  • error.specificity === 1
  • error.rule1_name + error.rule2_name match the two rule names

Negative cases (must NOT throw):

  • Two rules with same specificity but different transition types (COMMITMENT_ACCEPT_X and SETTLEMENT_COMPLETE_Y)
  • Two rules with same specificity but both have transitionType: null (e.g. Yield and Schism)

§P3.8. F7 — Duplicate-name detection

  • Two rules in the source with literally the same name
  • loadRuleset(...) throws AmbiguousRulesetError
  • error.specificity === -1
  • error.transition_type === null
  • error.message references “duplicate rule name”

§P3.9. F8 — getRule lookup

  • After loading 2 rules, getRule('rule1') returns the right RuleNode
  • getRule('') === null
  • getRule('NotARule') === null
  • Lookup is case-sensitive: getRule('rule1') !== getRule('RULE1')

§P3.10. F9 — getByTransitionType lookup

  • Source with one COMMITMENT_ACCEPT_, one SETTLEMENT_COMPLETE_, one no-prefix rule
  • getByTransitionType('COMMITMENT_ACCEPT').length === 1
  • getByTransitionType('SETTLEMENT_COMPLETE').length === 1
  • getByTransitionType('REPUTATION_DECAY').length === 0 (empty)
  • getByTransitionType('FORK_MERGE') === getByTransitionType('FORK_CREATE') for empty cases (both reference the shared frozen empty array — sanity check, not a load-bearing invariant)
  • Two COMMITMENT_ACCEPT_* rules with DIFFERENT specificities are returned in specificity-desc order

§P3.11. F10 — computeVersionHash stub

  • Empty registry: 'sha256:stub:0n'
  • 3-rule registry: 'sha256:stub:3n'
  • Same source loaded twice: hashes are equal
  • A registry with one extra rule: hash differs (even though the stub doesn’t use rule content)

§P3.12. F11 — Immutable post-construction

  • Loading a 1-rule registry, then attempting (registry as any).size = 999 should be a no-op or throw under strict mode (V8 throws TypeError on frozen object reassignment)
  • Object.isFrozen(registry) === true
  • Object.isFrozen(registry.getAll()) === true
  • Mutating the returned getAll() array (e.g. .push(...)) throws TypeError
  • The same getAll() reference is returned on subsequent calls (registry caches it as the allRules field)

§P4. Build + lint + test gate

cd .worktrees/claude/p1-2-4-registry
npm run build   # tsc -p tsconfig.build.json (or whatever the repo runs)
npm run lint    # eslint src/**/*.ts
npm test        # jest --config jest.config.cjs

All three gates per CLAUDE.md §5. The corpus self-scan in determinism.test.ts §rule-engine corpus self-scan will scan registry.ts for the 12 forbidden patterns; the implementation MUST pass that scan.

§P5. Rollback plan

If implementation reveals a contract gap, the following are revisable in Step 4 without re-running Steps 1–3:

  • Tweak the JSDoc on the // TODO(P1.5.1) comment.
  • Add a private helper that doesn’t change the public surface.
  • Adjust error message strings (the message format is documented in contract §2.4 but the exact wording is implementation-detail until callers depend on it).

The following changes WOULD require revising contract Step 2 (and thus this packet Step 3):

  • Adding or removing a public method.
  • Changing the signature of an existing public method.
  • Changing the public surface of any error class field.
  • Switching the prefix-convention for transition-type derivation to a different scheme.

§P6. Risk register

Risk Mitigation
Determinism corpus self-scan rejects registry.ts Implementation reviews every regex from determinism.ts FORBIDDEN_PATTERNS; no Math.*, no float literals, no async, etc.
Sort instability across Node versions Node 12+ guarantees stable Array.prototype.sort; tests assert declaration-order preservation explicitly
Object.freeze strict-mode regressions in tests Tests use try { ... } catch (e) { expect(e).toBeInstanceOf(TypeError) } to be tolerant of either silent no-op (sloppy mode) or thrown TypeError (strict mode); Jest test files run in strict mode by default since they are .ts (TypeScript emits strict)
getRule(undefined as any) from JS callers Contract specifies string parameter; TS compiler enforces; runtime falls through to Map.get(undefined) which returns undefined → coerced to null per §3.2. Test asserts.
Regex character-class escapes in error messages Error messages use plain string concatenation; no regex involved

§P7. Time budget

Step Task Est. minutes
4a Write registry.ts skeleton + types + constants 10
4b Implement loadRuleset algorithm 15
4c Implement helpers (computeSpecificity, matchTransitionTypePrefix) 5
4d Write all 11 test groups 25
4e npm run build && npm run lint && npm test first run + fixes 15
5 Verification doc 10
6 Commit + push + PR + writeback 5
Total   ~85 minutes

This is consistent with the M-effort estimate (4–8 hours) in the spec source — well under budget.


Back to top

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

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