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.mdanddocs/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 forrule <name> { guards { <guardBody> -> admit } effects { } }concat(...sources)→ joins source strings with\n\nexpectThrowsRulesetParse(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_TYPESis a key inCATEGORY_BY_TRANSITION_TYPE - Every value of
CATEGORY_BY_TRANSITION_TYPEis 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 RuleNodegetRule('Unknown')returnsnullgetByTransitionType('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(...)throwsRulesetParseErrorerror.errors.length >= 1error.messagecontainsRuleset 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(...)throwsRulesetValidationErrorerror.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(...)throwsAmbiguousRulesetErrorerror.transition_type === 'COMMITMENT_ACCEPT'error.specificity === 1error.rule1_name+error.rule2_namematch the two rule names
Negative cases (must NOT throw):
- Two rules with same specificity but different transition types (
COMMITMENT_ACCEPT_XandSETTLEMENT_COMPLETE_Y) - Two rules with same specificity but both have
transitionType: null(e.g.YieldandSchism)
§P3.8. F7 — Duplicate-name detection
- Two rules in the source with literally the same name
loadRuleset(...)throwsAmbiguousRulesetErrorerror.specificity === -1error.transition_type === nullerror.messagereferences “duplicate rule name”
§P3.9. F8 — getRule lookup
- After loading 2 rules,
getRule('rule1')returns the right RuleNode getRule('') === nullgetRule('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 === 1getByTransitionType('SETTLEMENT_COMPLETE').length === 1getByTransitionType('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 = 999should be a no-op or throw under strict mode (V8 throws TypeError on frozen object reassignment) Object.isFrozen(registry) === trueObject.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 theallRulesfield)
§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.