P1.2.4 — κ Rule Loader / Registry — Verification
Step 5 of the 5-step executor chain. Builds on
docs/audits/p1-2-4-registry-audit.md,docs/contracts/p1-2-4-registry-contract.md,docs/packets/p1-2-4-registry-packet.md. Closes the chain.
§1. Build / lint / test gate (CLAUDE.md §5)
All three gates run from the worktree root (.worktrees/claude/p1-2-4-registry/):
$ npm run build # tsc + postbuild copy-migrations
$ npm run lint # eslint src
$ npm test # jest --config jest.config.ts (full suite + coverage)
§1.1. Build — clean
> colibri@0.0.1 build
> tsc
> colibri@0.0.1 postbuild
> node scripts/copy-migrations.mjs
copy-migrations: copied 6 migration(s) ... -> dist/db/migrations
Zero TypeScript diagnostics. registry.ts compiles under the strict tsconfig (strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitAny).
§1.2. Lint — clean
> colibri@0.0.1 lint
> eslint src
Zero ESLint findings after fixing the initial unused-import warning (TransitionType was imported but unused in the test file; removed).
§1.3. Tests — full suite green
Test Suites: 36 passed, 36 total
Tests: 1724 passed, 1724 total
Snapshots: 0 total
Time: 42.751 s
All 36 suites pass. Test count delta: +67 tests (1657 baseline at R85 close → 1724 with registry). Coverage on src/domains/rules/registry.ts: 100% statements / 100% branches / 100% functions / 100% lines per Jest’s coverage report.
The κ corpus self-scan (src/__tests__/domains/rules/determinism.test.ts §rule-engine corpus self-scan) explicitly walks src/domains/rules/*.ts (excluding determinism.ts) and asserts no forbidden tokens. registry.ts is now in the scanned set; the test passed with scannedFiles >= 1 and zero matches against the 12-pattern manifest.
§2. Acceptance-criteria mapping (contract §10 → tests)
| Criterion | Implementation | Tests |
|---|---|---|
loadRuleset(source: string): RuleRegistry parses + validates + indexes |
RuleRegistry.loadRuleset in registry.ts — Stages 1–5 per contract §3.1 |
F2 (happy path), F3 (parse-error path), F4 (validation-error path), all of F5–F11 |
| Specificity sort: guard term count desc, declaration order asc | internals.sort((a, b) => b.specificity - a.specificity || a.declarationIndex - b.declarationIndex) (stable) |
F5.1, F5.2, F5.3, F5.4, F5.5 |
Specificity ties at load time → AmbiguousRulesetError |
Adjacent-pair scan after sort; throws on (spec eq) && (tt non-null eq) |
F6.1, F6.2 (negative), F6.3 (negative), F6.4 (message) |
getRule(name) named lookup |
Map<string, RuleNode> lookup; returns null (not undefined) on miss |
F8.1, F8.2, F8.3, F8.4, F8.5 |
getByTransitionType(type) indexed lookup |
Map<TransitionType, readonly RuleNode[]> lookup; returns shared frozen empty array on miss |
F9.1, F9.2, F9.3, F9.4, F9.5, F9.6, F9.7 |
computeVersionHash() stub |
Returns 'sha256:stub:' + size + 'n' with TODO(P1.5.1) marker |
F10.1, F10.2, F10.3, F10.4, F10.5 |
| Load-time error aggregation | Stage 2 walks every rule (no short-circuit); RulesetValidationError.errors carries the FULL list |
F4.2 verifies multi-rule aggregation surfaces errors from BOTH bad rules |
| 13 TransitionType values | TRANSITION_TYPES literal array of 13 values |
F1.1, F1.2, F1.4, F1.6 |
| Immutable post-construction | Object.freeze(this), Object.freeze(allRules), Object.freeze(emptyArray), Object.freeze(byType buckets) |
F11.1, F11.2, F11.3, F11.4, F11.5, F11.6 |
Two extra acceptance-relevant invariants verified (not in the original list but required by composition):
| Extra invariant | Tests |
|---|---|
Duplicate rule name → AmbiguousRulesetError(specificity:-1) |
F7.1, F7.2 |
| Empty source produces a valid empty registry | F2.5, F10.1 |
§3. Test inventory — 67 tests across 12 describe blocks
| Group | Describe block | Test count | Coverage focus |
|---|---|---|---|
| F1 | TransitionType enum + constants | 8 | Enum size, frozen state, mapping correctness |
| F2 | loadRuleset happy path | 5 | Wiring through parser + validator + indexes |
| F3 | loadRuleset parse-error aggregation | 4 | RulesetParseError fires before validation; carries full list |
| F4 | loadRuleset validator-error aggregation | 4 | RulesetValidationError aggregates across rules; no short-circuit |
| F5 | Specificity sort | 6 | High-spec wins; declaration-order ties; else=0; nested-and only at top |
| F6 | Specificity tie detection | 4 | Tie within type → throw; cross-type / both-null → no throw |
| F7 | Duplicate-name detection | 2 | Same name twice → AmbiguousRulesetError(specificity: -1) |
| F8 | getRule lookup | 5 | Known/unknown/empty/case-sensitive/reference-stable |
| F9 | getByTransitionType lookup | 7 | Match/order/empty/shared-frozen-empty/no-cross-leak/freeze |
| F10 | computeVersionHash stub | 5 | Format/determinism/size-sensitivity |
| F11 | Immutable post-construction | 6 | Object.frozen on registry + arrays; getAll returns same ref |
| Auxiliary | Pure helpers (matchTransitionTypePrefix, topLevelTerms, computeSpecificity) | 11 | Helper functions exported for granular testing |
| Total | 67 |
§4. Coverage report — registry.ts at 100%
From the Jest text reporter (full suite run):
File | % Stmts | % Branch | % Funcs | % Lines
registry.ts | 100 | 100 | 100 | 100
No uncovered lines. Every code path — happy load, parse-error throw, validator-error throw, specificity-tie throw, duplicate-name throw, all four getXxx methods, the computeVersionHash stub, the freeze-on-construct branch — is exercised at least once.
§5. Determinism corpus self-scan
The corpus self-scan asserts no forbidden tokens in any src/domains/rules/*.ts file (excluding determinism.ts itself). registry.ts joined the scanned set on this PR; all 12 patterns return zero matches:
| Pattern | Matches in registry.ts |
|---|---|
\bMath\.[A-Za-z_]\w* |
0 |
\bDate\.[A-Za-z_]\w* |
0 |
\bnew\s+Date\b |
0 |
| timer (setTimeout / setInterval / setImmediate) | 0 |
| network (fetch / XMLHttpRequest) | 0 |
require('fs') |
0 |
from 'fs' |
0 |
\bcrypto\. |
0 |
\bprocess\.(hrtime\|nextTick)\b |
0 |
\bawait\b |
0 |
\basync\s+(function\|\() |
0 |
float literal \d+\.\d+ |
0 |
The scanner strips comments before matching, so JSDoc may contain any term safely. The implementation uses no float literals at all (specificity is a JS integer, rule count is a JS integer, and bigint-style identifiers in computeVersionHash are produced via string concatenation, not BigInt(...)).
§6. Engine + sibling-slice compatibility
§6.1. Engine compatibility — RuleRegistry implements IRuleRegistry
The engine (src/domains/rules/engine.ts:202-204) declares the minimum interface:
export interface RuleRegistry {
getAll(): readonly CategorizedRule[];
}
registry.ts declares class RuleRegistry implements IRuleRegistry and provides exactly that method (plus four extensions). TypeScript’s structural typing enforced at compile time; the build is green.
The engine consumes getAll() and re-sorts by category + alphabetical-within-category for execution order. Specificity ordering does NOT determine engine execution order — it only determines tie-breaking within the registry’s name+type indexes. This separation is documented in contract §3 of the engine and §3.2 of this registry; the PR introduces no contract drift between the two.
§6.2. Sibling-slice compatibility — non-overlapping files
This task is dispatched in parallel with four sibling κ Wave 5 executors:
- P1.3.2 →
src/domains/rules/builtins.ts - P1.3.3 →
src/domains/rules/state-access.ts - P1.3.4 →
src/domains/rules/policy-gate.ts - P1.5.1 →
src/domains/rules/versioning.ts
registry.ts does not touch any of these paths and does not import from them. Each sibling slice is responsible for its own file; merge ordering between the five PRs is unaffected by sequence as long as each merges cleanly against main at the time of merge.
The only forward-coupling is P1.5.1 → registry: the P1.5.1 author will ship wireVersionHash(registry: RuleRegistry): RuleRegistry (per task prompt §P1.5.1 line 2264) which patches RuleRegistry.computeVersionHash. The // TODO(P1.5.1) comment marks the patch site; the stub implementation is designed to be overwritable without touching any other registry method. No other forward coupling exists.
§7. JSDoc + canonical references
The module docstring at the top of registry.ts lists the canonical references per contract §7:
docs/audits/p1-2-4-registry-audit.mddocs/contracts/p1-2-4-registry-contract.mddocs/packets/p1-2-4-registry-packet.mddocs/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. 5-step chain commit trail
| Step | Commit | Output |
|---|---|---|
| 1. Audit | e3c7b71c audit(p1-2-4-registry): inventory surface |
docs/audits/p1-2-4-registry-audit.md |
| 2. Contract | 2fe79a0a contract(p1-2-4-registry): behavioral contract |
docs/contracts/p1-2-4-registry-contract.md |
| 3. Packet | 30b6e4eb packet(p1-2-4-registry): execution plan |
docs/packets/p1-2-4-registry-packet.md |
| 4. Implement | 6481f05c feat(p1-2-4-registry): rule loader + indexed registry |
src/domains/rules/registry.ts + src/__tests__/domains/rules/registry.test.ts |
| 5. Verify | (this commit) | docs/verification/p1-2-4-registry-verification.md |
§9. Known caveats / observations
§9.1. Pre-existing flake in integer-math.test.ts
A single test in integer-math.test.ts (decay › rejects MAX_INT64 epochs in O(1) without hanging the process) contains a 100ms wall-clock threshold. Under heavy parallel test load it occasionally takes 113ms; in isolation it always passes. This flake is pre-existing — it predates this PR and lives in integer-math.ts, which this task does not touch. The full-suite run via npm test was green; the flake reproduces only when running with --no-coverage and a larger concurrency window. Documented in memory’s drift register; not a blocker.
§9.2. Naming convention is registry-local
The prefix-convention design (<TRANSITION_TYPE>_<remainder>) is owned by registry.ts alone — neither the lexer, parser, nor validator know or enforce it. A rule named Yield parses + validates fine; it just does not appear in any getByTransitionType(...) index. Authors who want a rule indexed by transition type must opt in by naming. A future task may add explicit @TYPE annotation tokens; if so, matchTransitionTypePrefix becomes the fallback and matchTransitionTypeAnnotation becomes primary, both in registry.ts.
§9.3. Registry is not a singleton
A host process may construct multiple RuleRegistry instances simultaneously (e.g. test fixture + production fixture + parity-harness fixture). The class is not a singleton. There is no global state. Any caller that wanted singleton semantics would have to wrap construction themselves. Documented in contract §5.
§9.4. Pre-existing startup-subprocess-smoke flake under load
A separate intermittent failure was observed once (out of three full-suite npm test runs during this verification): a server-side timing test in src/__tests__/server.test.ts / src/__tests__/startup.test.ts that depends on subprocess startup latency. It is the same pre-existing flake documented in memory’s R75/R77 drift register (“pre-existing startup — subprocess smoke flakiness under full-suite load — predates Wave H; all 4 R77 executors hit it once, always green on rerun”). Re-running npm test produced a clean 1724/1724 green. Not introduced by this PR; not a blocker.
§9.5. Promotion-category rules are unreachable in Phase 1
The prefix convention does not produce Promotion-category rules — no transition type maps to Promotion. This is intentional in Phase 1 (per audit §5.3 + contract §2.3). Future tasks that introduce promotion-shaped rules will need to extend either the prefix scheme or add an explicit annotation path.
§10. Sign-off
All five steps complete. Build / lint / test gates green. Coverage 100%. Determinism corpus self-scan green.
The registry implements the engine’s RuleRegistry interface plus the four spec-required extensions (getRule, getByTransitionType, computeVersionHash, size). It refuses boot on parse errors, validation errors, duplicate names, and specificity ties; it surfaces aggregated diagnostics on every error path; it is immutable post-construction; and it imposes the prefix-naming convention only as a registry-local concern that does not perturb any sibling module.
Ready for PR.