P1.2.4 — κ Rule Loader / Registry — Audit

Step 1 of the 5-step executor chain (audit → contract → packet → implement → verify). Builds on the P1.2.2 parser (src/domains/rules/parser.ts, R84 PR #205), the P1.2.3 validator (src/domains/rules/validator.ts, R85 PR #207), and the P1.3.1 engine (src/domains/rules/engine.ts, R85 PR #208), all on main at base SHA d766db59. Greenfield registry surface — no existing module to refactor.

§1. Surface inventory

§1.1. Target files (greenfield for this task)

Path Exists at base? Purpose
src/domains/rules/registry.ts No Indexed rule registry with specificity ordering; pure module
src/__tests__/domains/rules/registry.test.ts No Jest registry tests (see §1.3 layout reconciliation)

§1.2. Touched but not owned

Path Delta Purpose
src/domains/rules/ already exists with bps-constants.ts, canonical.ts, determinism.ts, engine.ts, integer-math.ts, lexer.ts, parser.ts, validator.ts (8 files) Adding registry.ts as a peer — no edits to existing files.
package.json none No new dependencies. The registry imports only parse + AST types from ./parser.js and validate + result types from ./validator.js.
package-lock.json none No new dependencies.

§1.3. Test-file layout reconciliation

The task prompt + the canonical task-breakdown row (§P1.2.4) both specify src/domains/rules/__tests__/registry.test.ts. The shipped Phase 0 + Phase 1 convention is tests live under src/__tests__/domains/<name>/, confirmed by inspection at base SHA d766db59:

  • src/__tests__/domains/rules/{bps-constants,canonical,determinism,engine,integer-math,lexer,parser,validator}.test.ts (P1.1.1, P1.1.2, P1.1.3, P1.2.1, P1.2.2, P1.2.3, P1.3.1, P1.5.4)
  • src/__tests__/domains/{router,skills,tasks,proof,trail}/... (Phase 0 axes)

The R84 P1.2.2 parser audit (§1.3) and the R85 P1.2.3 validator audit (§1.3) made the same reconciliation. To stay consistent with the in-repo κ tests already shipped, the registry test will live at:

src/__tests__/domains/rules/registry.test.ts

This is a convention reconciliation, not a spec deviation. The verification doc will re-cite.

§2. Authoritative source map

Source Path Weight
Task spec, ready-to-paste prompt docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.2.4 (lines 899–1041) Authoritative for behavior + acceptance
Task-breakdown row docs/guides/implementation/task-breakdown.md §P1.2.4 Authoritative for acceptance criteria list
Concept doc, rule application docs/3-world/physics/laws/rule-engine.md §Rule application algorithm + §Specificity ordering Authoritative for specificity definition + boot-time tie behavior
Heritage extraction §7 docs/reference/extractions/kappa-rule-engine-extraction.md §7 The 13 TransitionType values (COMMITMENT_CREATE → REPUTATION_DECAY)
Heritage extraction §8 docs/reference/extractions/kappa-rule-engine-extraction.md §8 RULE_REGISTRY shape + process_action() pseudocode
Parser source src/domains/rules/parser.ts The AST surface this registry indexes (RuleNode + 11 child node types)
Validator source src/domains/rules/validator.ts The validate(rule) → ValidationResult contract this registry composes
Engine source src/domains/rules/engine.ts The CategorizedRule + RuleRegistry interface this registry must implement (engine §1, lines 188–204)

§3. Drift findings

Two pre-existing drifts surfaced in earlier audits remain unresolved at base SHA d766db59. None blocks P1.2.4:

  1. docs/architecture/decisions/ADR-006-dsl-grammar.md does not exist. Slot is occupied by ADR-006-executable-meaning.md. The task prompt references “ADR-006 DSL grammar” once; the registry does not depend on that ADR. First raised in P1.2.1 audit, re-raised in P1.2.2 + P1.2.3 audits — out of scope here.
  2. @TYPE annotation lexer/parser support is absent. The task prompt’s preferred design — explicit @COMMITMENT_ACCEPT annotations on rule headers — would require lexer + parser changes. Coordinating those changes is an out-of-scope cross-cutting concern. The fallback option in the task prompt itself is the rule name prefix convention (“naming convention OR explicit annotation — pick (b) and add @IDENTIFIER handling … OR pick (a)”). This audit picks (a) and locks the convention in §6.

§4. Parser + validator + engine surfaces — what the registry binds to

§4.1. Parser inputs (already shipped P1.2.2)

The registry calls parse(source: string): ParseResult (parser §7, line 1049). ParseResult.ast is RuleNode[]; ParseResult.errors carries lex + parse + ast-cap diagnostics. Per the parser contract, parse never throws; every error flows through the errors field.

The registry must:

  • Pass through every ParseError at boot time as a structured aggregate failure (per acceptance criterion: “Load-time error aggregation: reports all validator errors in one pass” — extended to parser errors as well, since otherwise a malformed source produces a registry with too few rules and validator passes them silently).
  • Reject any ParseResult with errors.length > 0. Strict at load time.

§4.2. Validator inputs (already shipped P1.2.3)

The registry calls validate(rule: RuleNode): ValidationResult (validator §12, line 641). ValidationResult is a discriminated union: {valid: true} or {valid: false, errors: ValidationError[]}.

The registry must:

  • Run validate on every parsed RuleNode without short-circuit — collect all violations from all rules.
  • Aggregate every rule’s errors into a single rejection with one combined diagnostic.

§4.3. Engine consumption surface (already shipped P1.3.1)

The engine (engine §1, lines 188–204) declares two interfaces that the registry must satisfy:

export interface CategorizedRule {
  rule: RuleNode;
  category: Category;
}

export interface RuleRegistry {
  getAll(): readonly CategorizedRule[];
}

Category is an exported type alias 'Admission' | 'StateTransition' | 'Consequence' | 'Promotion'. The registry must compute one category for every rule (this audit picks the convention in §6.1).

The engine documents (engine line 195–200) that the registry may add additional methods (getByName, getByTransitionType, etc.). The engine itself only depends on getAll().

§5. The registry contract — design notes

§5.1. Specificity score

The acceptance criterion defines specificity as “guard term count descending.” The gotcha section in the task prompt clarifies: “A ‘term’ is a leaf expression in a comparison or logical combination. Count only top-level operands, not nested sub-expressions; otherwise specificity becomes sensitive to refactoring that should be semantically neutral.”

Working definition adopted by this registry: the specificity of a rule is the sum, over all its guard clauses, of the count of top-level conjunction terms in each clause’s condition.

  • An else clause (whose condition is null) contributes 0.
  • A clause whose condition is <expr> (no top-level and) contributes 1.
  • A clause whose condition is A and B and C contributes 3 — the top-level and chain is flattened.
  • A clause whose condition is (A or B) and C contributes 2 — the disjunction is one term, the comparison is one term, the top-level shape is and(orExpr, comparison).
  • Sub-expressions inside any one term are not recursively counted.

Rationale: this matches the “specificity rises when a rule narrows its precondition” intuition, while staying invariant under semantically neutral refactors (associativity rebracketing inside one term doesn’t change the count).

§5.2. Tie detection — specificity + transition-type co-equality

The acceptance criterion: “Specificity ties at load time → explicit AmbiguousRulesetError — refuse boot.”

But two rules with the same specificity but different applicable transition types are not really competing — they apply to different events. The intended check (per task prompt’s loadRuleset step 5) is: tie iff specificity equal AND applicable transition type equal.

Rules with transitionType: null (no recognized prefix) cannot create a tie of this kind, because they don’t claim a transition type. Two rules can share null for transition type — they’re free agents — and still be ordered solely by declaration order. (They will not be returned by getByTransitionType(any) either.)

§5.3. Category derivation

The engine requires a category: Category on every CategorizedRule. The registry must compute one. The 13 transition types fall into the four categories in a documented mapping (this is fixed by the rule-engine.md §Rule application algorithm — Admission rules guard entry, StateTransition rules apply effects, Consequence rules trigger downstream effects, Promotion rules elevate values). The mapping adopted here:

Category TransitionTypes
Admission COMMITMENT_CREATE, COMMITMENT_ACCEPT, DISPUTE_OPEN, GOVERNANCE_PROPOSE, IDENTITY_CREATE, FORK_CREATE
StateTransition SETTLEMENT_COMPLETE, SETTLEMENT_FAIL, DISPUTE_RESOLVE, GOVERNANCE_VOTE, IDENTITY_UPDATE, FORK_MERGE
Consequence REPUTATION_DECAY
Promotion (no transition type maps here; reserved for future Promotion-only rules)

Rules without a recognized prefix (transitionType: null) default to category StateTransition — the most neutral middle slot, ordered after Admission and before Consequence/Promotion. This is documented in the contract and not configurable.

§6. Naming convention (locked)

§6.1. Rule name prefix encoding

The RuleNode.name is the only metadata the parser produces about a rule’s intent. The registry adopts a prefix convention that encodes the transition type:

<TRANSITION_TYPE>_<remainder>

Where <TRANSITION_TYPE> is one of the 13 enum values from extraction §7, and <remainder> is anything (one or more identifier characters). The match is done by longest-prefix-wins scan over the 13 enum values. Examples:

  • COMMITMENT_ACCEPT_FastTrack → transitionType COMMITMENT_ACCEPT, category Admission
  • SETTLEMENT_COMPLETE_StandardPayout → transitionType SETTLEMENT_COMPLETE, category StateTransition
  • REPUTATION_DECAY_PerEpoch → transitionType REPUTATION_DECAY, category Consequence
  • Yield (no prefix) → transitionType null, category StateTransition (default)

The remainder MAY itself contain underscores; the prefix is matched only against the 13 enum values. The remainder MAY be empty ONLY if followed by another underscore — this avoids a name like COMMITMENT_ACCEPT (no remainder) being indistinguishable from a rule “named after” the transition type without further qualification. To remove ambiguity entirely, the convention requires at least one character of remainder: COMMITMENT_ACCEPT_<X> where <X> is one or more chars. A bare COMMITMENT_ACCEPT with no trailing underscore + remainder is treated as transitionType: null (no prefix matched).

Rationale: this convention is purely a registry concern — neither the lexer, parser, validator, nor engine require any change. Future tasks (or a P1.5.x sweep) may add explicit @TYPE annotation support; when that lands, the registry can switch to annotation-driven derivation while keeping the prefix convention as a fallback.

§6.2. Convention is NOT enforced by the validator

The validator (P1.2.3) does not check rule names against the prefix convention. A rule named Yield is valid (just orphaned from any transition-type index). Authors who want a rule to be retrievable via getByTransitionType must opt in by naming.

§7. computeVersionHash — stub

Per the task prompt: “computeVersionHash(): string — Phase 1 stub: return sha256:stub:${ruleset-length-bigint}. Replaced with P1.5.1 implementation when that lands.”

The Phase 1 stub returns a deterministic, content-free identifier (sha256:stub: followed by the rule count expressed as a bigint string — e.g. sha256:stub:3n for a 3-rule registry). It is purely a placeholder; it does not satisfy the version-hash invariants spec in P1.5.1. The P1.5.1 author will replace this with a real wireVersionHash patch (per task prompt §P1.5.1 line 2264) that swaps computeVersionHash for a SHA-256-over-canonicalize implementation.

The registry exposes a // TODO(P1.5.1) comment at the call site so the patch site is locatable.

§8. Determinism corpus self-scan compatibility

The registry runs under the κ corpus self-scan (src/__tests__/domains/rules/determinism.test.ts §rule-engine corpus self-scan, lines 833–890). Every .ts file in src/domains/rules/ (except determinism.ts itself) is scanned for the 12 forbidden patterns:

  • \bMath\., \bDate\., \bnew\s+Date\b, timer names (setTimeout / setInterval / setImmediate), network names (fetch / XMLHttpRequest), require('fs'), from 'fs', \bcrypto\., \bprocess\.(hrtime|nextTick)\b, \bawait\b, \basync\s+(function|\(), float literal \d+\.\d+.

Comments are stripped before scanning, so JSDoc may use any term safely. The implementation must:

  • Avoid every forbidden runtime pattern in source.
  • Avoid any float literal (1_000_000n is fine; 1.5 is not).
  • Avoid await/async — the registry is fully synchronous.
  • Avoid crypto.*computeVersionHash is a stub, NOT a real hash, so no crypto module is needed.

§9. Public surface — locked in contract Step 2

// Types
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[];
export const CATEGORY_BY_TRANSITION_TYPE: Readonly<Record<TransitionType, Category>>;

// Errors
export class AmbiguousRulesetError extends Error {
  readonly rule1_name: string;
  readonly rule2_name: string;
  readonly specificity: number;
  readonly transition_type: TransitionType | null;
}

export class RulesetParseError extends Error {
  readonly errors: readonly ParseError[];
}

export class RulesetValidationError extends Error {
  readonly errors: readonly ValidationError[];
}

// Class
export class RuleRegistry {
  static loadRuleset(source: string): RuleRegistry;
  getAll(): readonly CategorizedRule[];
  getRule(name: string): RuleNode | null;
  getByTransitionType(type: TransitionType): readonly RuleNode[];
  computeVersionHash(): string;
  readonly size: number;
}

§10. Acceptance criteria mapping

Criterion Implementation route Step-3 packet section
loadRuleset(source) parses + validates + indexes RuleRegistry.loadRuleset orchestrates parse → check parse errors → validate over each rule → aggregate validator errors → compute specificity → sort → tie detect → build indexes §P2.5
Specificity sort — guard term count desc, declaration order asc Stable sort: primary key -specificity, secondary key +declarationIndex §P2.6
Tie at load time → AmbiguousRulesetError After sort, scan adjacent pairs; if (spec, transitionType) equal AND transitionType !== null → throw §P2.7
getRule(name) → RuleNode \| null Map<string, RuleNode> lookup §P2.8
getByTransitionType(t) → RuleNode[] Map<TransitionType, RuleNode[]> lookup; returns sorted-by-specificity slice §P2.9
computeVersionHash(): string stub Return 'sha256:stub:' + size + 'n' §P2.10
Load-time error aggregation parse errors aggregated into RulesetParseError; validator errors across ALL rules aggregated into RulesetValidationError §P2.5
13 TransitionType enum values TRANSITION_TYPES + the union §P2.3
Immutable post-construction Constructor Object.freezes the registry; no mutator methods exposed §P2.4

§11. Worktree-scope inventory

Touched files in this task (all under .worktrees/claude/p1-2-4-registry/):

File Source / kind
docs/audits/p1-2-4-registry-audit.md this file (Step 1 commit)
docs/contracts/p1-2-4-registry-contract.md Step 2 commit
docs/packets/p1-2-4-registry-packet.md Step 3 commit
src/domains/rules/registry.ts Step 4 commit (impl)
src/__tests__/domains/rules/registry.test.ts Step 4 commit (tests)
docs/verification/p1-2-4-registry-verification.md Step 5 commit

No files outside this worktree are touched. PR #208 (P1.3.1) is the latest κ slice on main; this task is fully file-disjoint from the four R86 sibling executors per the dispatch packet (sibling targets: src/domains/rules/builtins.ts, state-access.ts, policy-gate.ts, versioning.ts).


Back to top

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

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