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 onmainat base SHAd766db59. 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:
docs/architecture/decisions/ADR-006-dsl-grammar.mddoes not exist. Slot is occupied byADR-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.@TYPEannotation lexer/parser support is absent. The task prompt’s preferred design — explicit@COMMITMENT_ACCEPTannotations 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@IDENTIFIERhandling … 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
ParseErrorat 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
ParseResultwitherrors.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
validateon every parsedRuleNodewithout 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
elseclause (whoseconditionisnull) contributes 0. - A clause whose condition is
<expr>(no top-leveland) contributes 1. - A clause whose condition is
A and B and Ccontributes 3 — the top-levelandchain is flattened. - A clause whose condition is
(A or B) and Ccontributes 2 — the disjunction is one term, the comparison is one term, the top-level shape isand(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→ transitionTypeCOMMITMENT_ACCEPT, categoryAdmissionSETTLEMENT_COMPLETE_StandardPayout→ transitionTypeSETTLEMENT_COMPLETE, categoryStateTransitionREPUTATION_DECAY_PerEpoch→ transitionTypeREPUTATION_DECAY, categoryConsequenceYield(no prefix) → transitionTypenull, categoryStateTransition(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_000nis fine;1.5is not). - Avoid
await/async— the registry is fully synchronous. - Avoid
crypto.*—computeVersionHashis 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).