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 forsrc/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— callsparse(source: string): ParseResult; type-only imports of AST node interfaces (RuleNode,Expression,LogicalOp)./validator.js— callsvalidate(rule: RuleNode): ValidationResult; type-only import ofValidationError./engine.js— type-only imports ofCategory,CategorizedRule, and theRuleRegistryinterface (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
Errorand set.nameviaoverride readonly name = '...'soinstanceofand.nameare both reliable. - Carry a human-readable
.message(via the constructorsupercall) 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]), ifspecificityis equal ANDtransitionTypeis non-null ANDtransitionTypeis equal, throwAmbiguousRulesetError. (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 withtransitionType === nullare NOT a tie becausenullis 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 triggersAmbiguousRulesetErrorwithtransition_type: nullandspecificity: -1as a sentinel. Decision: actually, simplest is to detect duplicate names independently of specificity — Stage 5 checksMap.has(name)before insert and throwsAmbiguousRulesetErrorwithspecificity: -1if 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. Markedreadonlyin the type signature; the underlying array isObject.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 areadonly 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 Bcounts as 1 term (the disjunction itself is one specificity claim).LogicalOp(op='not')is not flattened —not Xcounts 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 == 1 → 1 == $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 forgetByTransitionTypemisses is frozen.Mapinstances (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 areadonly 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
loadRulesetrepeatedly — 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 originalParseResult.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’sRuleRegistryinterface.
§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 —
computeVersionHashis 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 algorithmdocs/3-world/physics/laws/rule-engine.md §Specificity orderingdocs/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.mddocs/spec/s12-dsl.md
§8. Out of scope (Phase 1 deferrals)
@TYPEannotation lexer/parser support. Replaced by the prefix convention (audit §6.1). A future task may add real@COMMITMENT_ACCEPTannotation tokens; when that ships, the registry can switch its derivation function.- Real version hash.
computeVersionHashis a stub; P1.5.1 (parallel sibling slice this wave) implementswireVersionHash(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
cycleDetectionstub 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 |