P1.2.4 — κ Rule Loader / Registry — Behavioral Contract

Step 2 of the 5-step executor chain. Builds on docs/audits/p1-2-4-registry-audit.md. Defines the public surface, semantics, and invariants for src/domains/rules/registry.ts.

§1. Module identity

  • Path: src/domains/rules/registry.ts
  • Axis: κ — Rule Engine (Phase 1 Wave 5)
  • Kind: pure synchronous module; no I/O, no DB access, no network, no env reads, no console output
  • Runtime dependencies:
    • ./parser.js — calls parse(source: string): ParseResult; type-only imports of AST node interfaces (RuleNode, Expression, LogicalOp)
    • ./validator.js — calls validate(rule: RuleNode): ValidationResult; type-only import of ValidationError
    • ./engine.js — type-only imports of Category, CategorizedRule, and the RuleRegistry interface (the registry implements that interface plus extensions)
  • No imports from src/db/*, src/middleware/*, src/domains/{tasks,skills,trail,proof,router,integrations}/*, or any Node built-ins.

§2. Public API

§2.1. Re-exported / referenced types from siblings

The registry imports type-only:

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

(Expression is needed to walk top-level conjunctions; the type-only modifier means there is no runtime dependency on parser.ts at import — only at the function-call site inside loadRuleset.)

§2.2. The TransitionType enum

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';

13 string literal values, exactly matching extraction §7. The order in this declaration is the canonical order used by TRANSITION_TYPES and by any iteration in tests.

§2.3. Public constants

/** All 13 TransitionType values, frozen, in canonical declaration order. */
export const TRANSITION_TYPES: readonly TransitionType[];

/** Mapping from each TransitionType to its rule-execution Category. Frozen. */
export const CATEGORY_BY_TRANSITION_TYPE: Readonly<Record<TransitionType, Category>>;

/** Default category assigned to rules with no recognized transition-type prefix. */
export const DEFAULT_CATEGORY: Category;  // 'StateTransition'

The CATEGORY_BY_TRANSITION_TYPE mapping (locked):

TransitionType Category
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

No transition type maps to Promotion in Phase 1. Promotion-category rules cannot be created via the prefix convention; they would require a future @PROMOTE annotation or a registry override (out of scope here).

§2.4. Error classes

/**
 * Thrown by `loadRuleset` when two rules end up with the same
 * specificity score AND the same applicable transition type.
 *
 * `transition_type` is `null` only when both rules have no recognized
 * prefix and the registry is configured to treat null-typed ties as
 * fatal (Phase 1 default: null-typed ties are NOT fatal — they fall
 * through to declaration-order ordering).
 */
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;
}

/**
 * Thrown by `loadRuleset` when the parser returns one or more `ParseError`s.
 * `errors` carries every parser-level diagnostic (lex + parse + ast-cap).
 */
export class RulesetParseError extends Error {
  override readonly name = 'RulesetParseError';
  readonly errors: readonly ParseError[];
}

/**
 * Thrown by `loadRuleset` when ANY rule fails validation. Carries the
 * full aggregated list across every rule — not just the first failing
 * rule's errors. Each `ValidationError.message` is preserved verbatim
 * from the validator.
 */
export class RulesetValidationError extends Error {
  override readonly name = 'RulesetValidationError';
  readonly errors: readonly ValidationError[];
}

All three classes:

  • Extend Error and set .name via override readonly name = '...' so instanceof and .name are both reliable.
  • Carry a human-readable .message (via the constructor super call) suitable for log lines.
  • Are pure data — no I/O, no DB lookups.

§2.5. The RuleRegistry class

export class RuleRegistry implements IRuleRegistry {
  /** Build a registry from a source string. Static factory; the constructor is private. */
  static loadRuleset(source: string): RuleRegistry;

  /** All categorized rules, sorted by specificity desc → declaration order asc. */
  getAll(): readonly CategorizedRule[];

  /** Lookup by exact rule name. Returns `null` (not undefined) if the name is unknown. */
  getRule(name: string): RuleNode | null;

  /** Returns rules applicable to the given transition type, sorted by specificity desc. */
  getByTransitionType(type: TransitionType): readonly RuleNode[];

  /** Phase 1 stub. Returns 'sha256:stub:<size>n'. P1.5.1 will replace this body. */
  computeVersionHash(): string;

  /** Number of rules in the registry. Read-only. */
  readonly size: number;
}

The IRuleRegistry interface (declared in engine.ts) requires only getAll(): readonly CategorizedRule[]. The registry implements that and adds the four extensions named in the acceptance criteria.

The constructor is private (TypeScript-level — private constructor) so consumers MUST go through loadRuleset. This prevents callers from constructing a registry with un-validated rules.

§3. Semantics

§3.1. loadRuleset(source) algorithm

The static factory orchestrates parser → validator → indexer in five stages:

Stage 1 — Parse. Call parse(source). If parseResult.errors.length > 0, throw RulesetParseError carrying every parse error (lex + parse + ast-cap). The RulesetParseError.message is "Ruleset parse failed (<n> error(s))" where <n> is errors.length.

Stage 2 — Validate every rule, aggregating errors. For each rule in parseResult.ast, call validate(rule). If the result is {valid: false}, accumulate every ValidationError (rule name is included in the message — TODO: revisit if validator tests fail this fixture). After all rules are validated, if the accumulator is non-empty, throw RulesetValidationError carrying the full aggregated list. No short-circuit — every rule is validated even if rule 0 fails.

Stage 3 — Compute specificity per rule. For each rule, count its top-level guard terms (per §3.3 below). Pair each rule with its specificity score and its declaration index (its 0-based position in parseResult.ast).

Stage 4 — Compute transition type + category per rule. Match each rule’s name against the 13-value prefix dictionary (per audit §6.1). On match: assign the corresponding category from CATEGORY_BY_TRANSITION_TYPE. On miss: assign DEFAULT_CATEGORY (StateTransition) and transitionType: null.

Stage 5 — Sort + tie-detect + index.

  • Sort rules: primary key -specificity, secondary key +declarationIndex. The sort is stable (preserves the secondary key on equal primary key).
  • Adjacency tie scan: walk the sorted array; for each adjacent pair (rules[i], rules[i+1]), if specificity is equal AND transitionType is non-null AND transitionType is equal, throw AmbiguousRulesetError. (Two rules with the same specificity but different non-null transition types are NOT a tie because they are not in competition for the same event. Two rules with transitionType === null are NOT a tie because null is not a positive claim of applicability.)
  • Build the name index: Map<string, RuleNode>. Rule names are guaranteed unique by AmbiguousRulesetError’s broader cousin (a duplicate name across the same prefix would already trigger a tie); a duplicate name across no-prefix rules is checked separately and triggers AmbiguousRulesetError with transition_type: null and specificity: -1 as a sentinel. Decision: actually, simplest is to detect duplicate names independently of specificity — Stage 5 checks Map.has(name) before insert and throws AmbiguousRulesetError with specificity: -1 if a duplicate name is found. This is a separate failure mode from the specificity tie.
  • Build the type index: Map<TransitionType, RuleNode[]>. For each rule with non-null transitionType, append to that key’s array (preserving sorted order — since the rules are already sorted, append-in-sorted-iteration produces a sorted slice).

The constructor stores the sorted CategorizedRule[], the name map, and the type map; it freezes the registry instance with Object.freeze(this).

§3.2. getAll(), getRule(), getByTransitionType(), computeVersionHash(), size

  • getAll() — returns the stored sorted array. Marked readonly in the type signature; the underlying array is Object.freezed at construction time so even non-TS callers cannot mutate.
  • getRule(name)this.byName.get(name) ?? null. Pure map lookup. Names are case-sensitive (exact match).
  • getByTransitionType(type)this.byType.get(type) ?? <emptyFrozenArray>. The empty array is shared across all types with no rules to avoid per-call allocation. Returns a readonly RuleNode[].
  • computeVersionHash() — returns the literal string 'sha256:stub:' + this.size + 'n'. Phase 1 stub. The 'n' suffix marks the count as a bigint-style identifier. P1.5.1 will overwrite this body with a SHA-256 hash. A // TODO(P1.5.1) JSDoc comment marks the patch site.
  • size — number of rules; read-only public field, set in the constructor.

§3.3. Specificity score — formal definition

specificity(rule)  =  Σ over guards: topLevelTerms(guard.condition)

topLevelTerms(condition):
  if condition is null:        return 0       // else clause
  if condition is LogicalOp(op='and', operands=[L, R]):
                               return topLevelTerms(L) + topLevelTerms(R)
  else:                        return 1

Notes:

  • LogicalOp(op='and') carries exactly two operands per parser §2.6 (chains nest left, not flatten). The recursive sum walks the left-leaning chain and counts each leaf.
  • LogicalOp(op='or') is not flattened — A or B counts as 1 term (the disjunction itself is one specificity claim).
  • LogicalOp(op='not') is not flattened — not X counts as 1 term.
  • All other expression node types (BinaryOp, UnaryOp, IntLiteral, BoolLiteral, StringLiteral, VarRef, FuncCall) count as 1 term regardless of internal structure.

This definition is invariant under semantically neutral refactors of the body of any one term (e.g. $event.type == 11 == $event.type swaps left+right but stays 1 term).

§3.4. Prefix-matching algorithm

matchTransitionTypePrefix(name):
  for type in TRANSITION_TYPES (canonical order):
    if name == type:                    continue   // bare name; no remainder
    if name starts with type + '_':
      remainder = name after (len(type) + 1)
      if remainder.length >= 1:         return type
  return null

The check name == type filtering excludes the case where a rule is named exactly COMMITMENT_ACCEPT with no remainder — this is treated as a prefix-mismatch (no transition type assigned). The + '_' separator enforces that the prefix is followed by an underscore + at least one character of remainder.

The 13 enum values do not have any prefix-overlap (no enum value is a prefix of another with _ boundary), so the iteration order of TRANSITION_TYPES does not matter for correctness. (For example, IDENTITY_CREATE and IDENTITY_UPDATE are not prefixes of each other.)

§3.5. Read-only / immutable invariant

The registry is immutable post-construction:

  • Object.freeze(this) at end of constructor — no field can be reassigned.
  • Object.freeze(this.allRules) — the categorized array is frozen.
  • Object.freeze(emptyArray) — the shared empty array for getByTransitionType misses is frozen.
  • Map instances (byName, byType) are not frozen (Maps don’t freeze in V8), but they are not exposed — only their .get(...) is reachable through methods.
  • byType.get(t) returns a readonly RuleNode[] that was frozen at construction time.

§3.6. Sort stability

The TypeScript stdlib sort (Array.prototype.sort) is stable in all modern V8/Node versions (Node 12+). Stability is required because rules with equal specificity must fall back on declaration order. The implementation relies on this; the test suite includes a fixture that asserts declaration order is preserved within a specificity bucket.

§3.7. The empty-source case

loadRuleset('') (or any source that parses to zero rules with zero errors) returns a registry with size === 0, getAll() === [], getRule(any) === null, getByTransitionType(any) === [], computeVersionHash() === 'sha256:stub:0n'. No throw.

§3.8. The error-aggregation invariant

If the source has BOTH parse errors AND rules that would fail validation, the registry throws RulesetParseError first (Stage 1) and never reaches validation. Parse errors are a higher priority because a malformed source produces an unreliable AST that cannot be validated meaningfully. This is the “load-time error aggregation” criterion specifically: parse errors are aggregated within RulesetParseError; validator errors are aggregated within RulesetValidationError. Each error class carries the FULL aggregated list, not just the first error.

§4. Error code reference

Class When thrown Carries
RulesetParseError parse(source).errors.length > 0 errors: readonly ParseError[]
RulesetValidationError any validate(rule) returned {valid: false} errors: readonly ValidationError[] (across all failing rules)
AmbiguousRulesetError (specificity tie) (spec equal) && (tt non-null equal) between two adjacent sorted rules rule1_name, rule2_name, specificity: number, transition_type: TransitionType
AmbiguousRulesetError (duplicate name) two rules share RuleNode.name (regardless of specificity) rule1_name, rule2_name, specificity: -1, transition_type: null

§5. Composition contract — what consumers may assume

Callers of RuleRegistry:

  • MAY call loadRuleset repeatedly — each call constructs a fresh, independent registry. There is NO singleton state.
  • MAY hold multiple registries simultaneously (e.g. test fixture + production fixture).
  • MUST NOT assume that two registries built from the same source string are reference-equal — they are deep-equal but distinct instances.
  • MUST NOT mutate any returned RuleNode — they are shared references into the original ParseResult.ast. Mutation would violate the read-only invariant of every layer above.
  • MAY safely re-pass a registry to executeRuleset(registry, event, state, rule_version, epoch) (engine §5) — the registry implements the engine’s RuleRegistry interface.

§6. Determinism + corpus-scan compatibility

The implementation respects the κ corpus self-scan (per audit §8). Specifically:

  • No Math.* — use bigint comparisons or pure-int comparisons via < / > on JS numbers (specificity is a small JS number, not a bigint).
  • No Date.* / no timers — registry construction is synchronous and clock-free.
  • No network / fs / crypto — computeVersionHash is a stub, NOT a hash; no crypto module is needed.
  • No await / async — fully synchronous.
  • No float literals — all numeric constants are integer literals.

The implementation is also free of locale-aware comparisons (no localeCompare). String comparisons in the prefix matcher use String.prototype.startsWith (ECMAScript-defined ordering, locale-free).

§7. JSDoc + canonical references

The module docstring at the top of registry.ts will reference:

  • docs/audits/p1-2-4-registry-audit.md
  • this contract
  • docs/packets/p1-2-4-registry-packet.md (Step 3)
  • docs/3-world/physics/laws/rule-engine.md §Rule application algorithm
  • docs/3-world/physics/laws/rule-engine.md §Specificity ordering
  • docs/reference/extractions/kappa-rule-engine-extraction.md §7 (TransitionType)
  • docs/reference/extractions/kappa-rule-engine-extraction.md §8 (RULE_REGISTRY)
  • docs/spec/s11-rule-engine.md
  • docs/spec/s12-dsl.md

§8. Out of scope (Phase 1 deferrals)

  • @TYPE annotation lexer/parser support. Replaced by the prefix convention (audit §6.1). A future task may add real @COMMITMENT_ACCEPT annotation tokens; when that ships, the registry can switch its derivation function.
  • Real version hash. computeVersionHash is a stub; P1.5.1 (parallel sibling slice this wave) implements wireVersionHash(registry: RuleRegistry) to patch the method.
  • Built-in policy registration. Policies (P1-P13 from extraction §9) are pre-guards distinct from rules; their registration is P1.3.4’s concern.
  • Cross-rule cycle detection. The validator’s cycleDetection stub returns [] today (validator §10). The acceptance criterion does not require the registry to add a graph-level cycle check; that is reserved for a future κ task.
  • Promotion-category rules. No transition type maps to Promotion in Phase 1. A future annotation-driven path would add support.

§9. Test surface — locked names

The test file at src/__tests__/domains/rules/registry.test.ts covers eleven groups. Each group corresponds to one acceptance criterion or one invariant; each group has one or more test(...) cases. Names locked here so the verification doc (Step 5) can re-cite:

Group Coverage
F1 — TransitionType enum + constants TRANSITION_TYPES has 13 values; CATEGORY_BY_TRANSITION_TYPE maps all 13
F2 — loadRuleset happy path 3-rule source → registry with 3 entries
F3 — loadRuleset parse-error aggregation RulesetParseError carries every parse error
F4 — loadRuleset validator-error aggregation RulesetValidationError carries errors from ALL failing rules
F5 — Specificity sort High-specificity rule first; ties fall to declaration order
F6 — Tie detection identical (specificity, transitionType) pair → AmbiguousRulesetError
F7 — Duplicate-name detection two rules with identical name → AmbiguousRulesetError
F8 — getRule lookup known name → RuleNode; unknown name → null
F9 — getByTransitionType lookup matching prefix → expected rules; unmatched type → empty array
F10 — computeVersionHash stub non-empty string; encodes size
F11 — Immutable post-construction direct mutation attempts fail in strict mode; underlying arrays are frozen

Back to top

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

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