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.md
  • docs/contracts/p1-2-4-registry-contract.md
  • docs/packets/p1-2-4-registry-packet.md
  • 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. 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.


Back to top

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

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