P1.4.1 — Admission Evaluator — Execution Packet (Step 3 / 5)

The packet is the implementation plan. Step 4 produces source files that match this packet line-for-line. Where the packet is silent, Step 4 may exercise judgment; where the packet is explicit, Step 4 has no discretion.

§P0. Inputs

§P1. File layout

Path Kind Approximate LOC
src/domains/rules/admission.ts New source file ~250
src/__tests__/domains/rules/admission.test.ts New test file ~700
docs/audits/p1-4-1-admission-audit.md Existing (Step 1)
docs/contracts/p1-4-1-admission-contract.md Existing (Step 2)
docs/packets/p1-4-1-admission-packet.md This file (Step 3)
docs/verification/p1-4-1-admission-verification.md Future (Step 5)

No other source or doc files are modified. No README changes; no top-level ADR; no colibri_code graduation in this round (κ remains spec-only at the concept-doc level — only individual sub-tasks ship code).

§P2. Module skeleton — admission.ts

/**
 * Colibri — Phase 1 κ Rule Engine — Admission Evaluator (P1.4.1).
 *
 * Pure composition layer that turns a tool-call request into an admit-or-deny
 * verdict. Wraps three already-shipped slices:
 *
 *   1. P1.5.1 verifyRuleVersion — constant-time rule_version comparison.
 *   2. P1.3.4 check_all_policies — P1..P13 constitutional pre-guards.
 *   3. P1.3.1 executeRuleset — named-rule evaluation.
 *
 * Pure module — no I/O, no DB, no network, no env reads, no console output,
 * no clock, no RNG, no async. Determinism corpus self-scan
 * (`inspectFunctionForbidden`) over `evaluateAdmission` is asserted in the
 * test suite.
 *
 * Public surface (per docs/contracts/p1-4-1-admission-contract.md §2):
 *   - AdmissionMode, AdmissionRequest, DenialReason, AdmissionResult types
 *   - evaluateAdmission(req, registry): AdmissionResult
 *   - verifyRuleVersion (re-exported from versioning.ts)
 *
 * Algorithm (contract §3):
 *   1. Constant-time rule-version check FIRST. Mismatch → rule_version_mismatch.
 *   2. Build engine-facing actor + state records.
 *   3. check_all_policies(...). Denial → policy.
 *   4. executeRuleset(...).
 *   5. Disposition: first rejection in CATEGORY_ORDER, or all NO_MATCH →
 *      no_rule_matched, or admit with collected mutations.
 *
 * Canonical references:
 *   - docs/audits/p1-4-1-admission-audit.md
 *   - docs/contracts/p1-4-1-admission-contract.md
 *   - docs/packets/p1-4-1-admission-packet.md
 *   - docs/spec/s10-admission.md
 *   - docs/3-world/physics/laws/rule-engine.md §Admission layer
 *   - docs/reference/extractions/kappa-rule-engine-extraction.md §8 + §9
 */

import {
  CATEGORY_ORDER,
  executeRuleset,
} from './engine.js';
import type {
  Category,
  CategorizedRule,
  Mutation,
  RuleResult,
} from './engine.js';
import { check_all_policies } from './policy-gate.js';
import type { RuleRegistry } from './registry.js';
import type { ReadOnlyState } from './state-access.js';
import { verifyRuleVersion } from './versioning.js';

// =============================================================================
// §1. Public types
// =============================================================================

export type AdmissionMode = 'normal' | 'readonly' | 'admin';

export interface AdmissionRequest {
  readonly caller: string;
  readonly tool: string;
  readonly mode: AdmissionMode;
  readonly rep_snapshot: ReadOnlyState;
  readonly rule_version: string;
}

export type DenialReason =
  | { readonly kind: 'rule_version_mismatch'; readonly expected: string; readonly actual: string }
  | { readonly kind: 'policy'; readonly policy_reason: string }
  | { readonly kind: 'rule_rejected'; readonly rule_name: string; readonly rule_reason: string }
  | { readonly kind: 'no_rule_matched' };

export type AdmissionResult =
  | { readonly admitted: true; readonly effect_mutations: readonly Mutation[]; readonly rule_version: string }
  | { readonly admitted: false; readonly reason: DenialReason; readonly rule_version: string };

// =============================================================================
// §2. Public function — evaluateAdmission
// =============================================================================

/**
 * Evaluate an admission request against a rule registry.
 *
 * Total — never throws. Any internal error from the engine or policy gate is
 * caught and projected into an AdmissionResult with admitted: false. See
 * contract §2.3 for the four DenialReason discriminants.
 */
export function evaluateAdmission(
  req: AdmissionRequest,
  registry: RuleRegistry,
): AdmissionResult {
  // Step 1 — constant-time version check FIRST.
  // ...

  // Step 2 — build engine-facing records.
  // ...

  // Step 3 — policy pre-guards.
  // ...

  // Step 4 — named-rule evaluation.
  // ...

  // Step 5 — disposition.
  // ...
}

// =============================================================================
// §3. Internal helpers — first-rejection projection, no-match detection
// =============================================================================

/**
 * Walk per_category_results in CATEGORY_ORDER. Return the first rejection's
 * (rule_name, reason) plus a flag indicating whether ALL rejections in the
 * map are NO_MATCH (no guard matched any rule).
 *
 * Recovers rule_name by re-walking the registry's getAll() filtered by
 * category in the same order the engine walked it (which is alpha-sorted
 * within category).
 */
interface RejectionScan {
  readonly first: { readonly category: Category; readonly rule_name: string; readonly reason: string } | null;
  readonly any_admitted: boolean;
  readonly all_no_match: boolean;
  readonly any_rejection: boolean;
}

function scanRejections(
  per_category_results: ReadonlyMap<Category, readonly RuleResult[]>,
  registry: RuleRegistry,
): RejectionScan {
  // ...
}

// =============================================================================
// §4. Re-export — verifyRuleVersion
// =============================================================================

export { verifyRuleVersion } from './versioning.js';

§P3. Algorithm details

§P3.1. Step 1 — version check

const v_actual = registry.computeVersionHash();
if (!verifyRuleVersion(req.rule_version, v_actual)) {
  return {
    admitted: false,
    reason: {
      kind: 'rule_version_mismatch',
      expected: v_actual,
      actual: req.rule_version,
    },
    rule_version: v_actual,
  };
}

The verifyRuleVersion argument order is (expected, actual). We pass req.rule_version as the first argument — but verifyRuleVersion is symmetric in its observable behavior, so order doesn’t affect correctness. We document the convention in a comment for readability.

§P3.2. Step 2 — record construction

const stateRecord = req.rep_snapshot.toEngineState();
const actorRecord = { id: req.caller, mode: req.mode };
const eventRecord = { actor: req.caller, tool: req.tool, mode: req.mode };

actorRecord is what check_all_policies expects (extraction §9 — the policy gate does context.with_binding('actor', actor), which is the synthetic shape our adapter consumes via event.actor). eventRecord carries the same caller plus tool and mode so named rules can read both.

§P3.3. Step 3 — policy gate

const policy_result = check_all_policies(req.tool, actorRecord, stateRecord);
if (!policy_result.admitted) {
  return {
    admitted: false,
    reason: { kind: 'policy', policy_reason: policy_result.reason },
    rule_version: v_actual,
  };
}

§P3.4. Step 4 — engine

const transition = executeRuleset(
  registry,
  eventRecord,
  stateRecord,
  v_actual,
  req.rep_snapshot.epoch,
);

§P3.5. Step 5 — disposition

const scan = scanRejections(transition.per_category_results, registry);

// Case (a) — at least one rule admitted (mutations may be empty).
if (scan.any_admitted) {
  // Defensive copy: contract I12 — mutations array is fresh per call.
  return {
    admitted: true,
    effect_mutations: [...transition.all_mutations],
    rule_version: v_actual,
  };
}

// Case (b) — empty registry OR every rule was NO_MATCH.
if (!scan.any_rejection || scan.all_no_match) {
  return {
    admitted: false,
    reason: { kind: 'no_rule_matched' },
    rule_version: v_actual,
  };
}

// Case (c) — at least one rule rejected for a non-NO_MATCH reason.
const first = scan.first!;
return {
  admitted: false,
  reason: {
    kind: 'rule_rejected',
    rule_name: first.rule_name,
    rule_reason: first.reason,
  },
  rule_version: v_actual,
};

§P3.6. scanRejections body

function scanRejections(
  per_category_results: ReadonlyMap<Category, readonly RuleResult[]>,
  registry: RuleRegistry,
): RejectionScan {
  // Pre-bucket the registry's rules by category, alpha-sorted within. The
  // engine walks them in this exact order (engine.ts §5), so the index in
  // per_category_results.get(c) matches the index in this bucket.
  const all = registry.getAll();
  const bucketsByCategory = new Map<Category, CategorizedRule[]>();
  for (const c of CATEGORY_ORDER) {
    bucketsByCategory.set(c, []);
  }
  for (const cr of all) {
    bucketsByCategory.get(cr.category)!.push(cr);
  }
  for (const c of CATEGORY_ORDER) {
    bucketsByCategory.get(c)!.sort((a, b) => {
      if (a.rule.name < b.rule.name) return -1;
      if (a.rule.name > b.rule.name) return 1;
      return 0;
    });
  }

  let first: { category: Category; rule_name: string; reason: string } | null = null;
  let any_admitted = false;
  let all_no_match = true;
  let any_rejection = false;

  for (const c of CATEGORY_ORDER) {
    const results = per_category_results.get(c) ?? [];
    const rules = bucketsByCategory.get(c)!;
    for (let i = 0; i < results.length; i += 1) {
      const r = results[i]!;
      if (r.status === 'admitted') {
        any_admitted = true;
      } else {
        any_rejection = true;
        if (r.reason !== 'NO_MATCH') {
          all_no_match = false;
        }
        if (first === null) {
          // The engine walked rules in this same order, so rules[i] is the
          // rule that produced results[i].
          first = {
            category: c,
            rule_name: rules[i]?.rule.name ?? '<unknown>',
            reason: r.reason,
          };
        }
      }
    }
  }

  return { first, any_admitted, all_no_match, any_rejection };
}

The fallback '<unknown>' is unreachable in practice (the registry walked the same rules the engine did). Defensive coding only; we assert the unreachable branch in tests.

§P4. Test fixture taxonomy

§P4.1. Helpers

import { makeReadOnlyState } from '../../../domains/rules/state-access.js';
import { RuleRegistry } from '../../../domains/rules/registry.js';
import { evaluateAdmission, verifyRuleVersion } from '../../../domains/rules/admission.js';
import type { AdmissionRequest, AdmissionResult, AdmissionMode } from '../../../domains/rules/admission.js';
import { inspectFunctionForbidden } from '../../../domains/rules/determinism.js';

const HEX64 = 'a'.repeat(64); // valid lowercase 64-char hex.

function mkState(overrides: Partial<{ epoch: bigint; event_count: bigint; fork_id: string; rule_version: string }> = {}) {
  return makeReadOnlyState({
    epoch: overrides.epoch ?? 1n,
    event_count: overrides.event_count ?? 0n,
    fork_id: overrides.fork_id ?? HEX64,
    rule_version: overrides.rule_version ?? HEX64,
  });
}

function mkReq(overrides: Partial<AdmissionRequest> & { caller?: string; tool?: string; mode?: AdmissionMode; rule_version?: string } = {}): AdmissionRequest {
  return {
    caller: overrides.caller ?? 'alice',
    tool: overrides.tool ?? 'create_task',
    mode: overrides.mode ?? 'normal',
    rep_snapshot: overrides.rep_snapshot ?? mkState(),
    rule_version: overrides.rule_version ?? '__match__',  // sentinel; see fixtures.
  };
}

/**
 * Build a registry, then build a request whose rule_version matches the
 * registry's hash. Convenience wrapper for the happy-path fixtures.
 */
function mkPair(source: string, reqOverrides: Partial<AdmissionRequest> = {}) {
  const registry = RuleRegistry.loadRuleset(source);
  const req: AdmissionRequest = {
    caller: 'alice',
    tool: 'create_task',
    mode: 'normal',
    rep_snapshot: mkState(),
    rule_version: registry.computeVersionHash(),
    ...reqOverrides,
  };
  return { registry, req };
}

§P4.2. Fixture families

ID Family Cases Expected
F1.1 Module shape 1 exports evaluateAdmission, verifyRuleVersion, type discriminants exist
F2.1 Empty registry → no_rule_matched 1 {admitted: false, reason: {kind: 'no_rule_matched'}}
F2.2 Single rule, all guards admit, no effects 1 admit with effect_mutations: []
F2.3 Single rule, admit + emit effect 1 admit with 1 mutation (kind: ‘emit’)
F2.4 Single rule, admit + set effect 1 admit with 1 mutation (kind: ‘set’)
F2.5 Single rule, multiple effects 1 admit with N mutations in declaration order
F2.6 Two rules, both admit 1 admit with concatenated mutations
F2.7 Rule with reject "<reason>" 1 rule_rejected with verbatim reason
F2.8 Rule with reject "needs_admin" for non-admin caller 1 rule_rejected with 'needs_admin'
F3.1 Rule-version mismatch — empty string 1 rule_version_mismatch with both fields populated
F3.2 Rule-version mismatch — different valid hash 1 rule_version_mismatch
F3.3 Rule-version mismatch — engine NEVER ran (verified by spying / impossible-rule registry) 1 engine.executeRuleset side effect never triggered
F3.4 Rule-version mismatch — policy gate NEVER ran 1 (same idea — empty rule that would otherwise reject)
F4.1 Policy denies — verbatim reason flows through 1 (placeholder — current stub admits-all; we test via test-side construction of a failing-policy table not directly, so we exercise via the all-true stub returning admitted: true)
F4.2 Engine never runs after policy denial 1 (n/a in the stub baseline; we exercise the path with a rule_version mismatch which is the path that DOES short-circuit)
F5.1 First rejection in CATEGORY_ORDER (Admission first) 1 rule_rejected name/reason match the first Admission rule
F5.2 Tie within Admission — alpha sort decides 1 rule_rejected name = alpha-first
F5.3 NO_MATCH dominates — collapse to no_rule_matched 1 no_rule_matched (not rule_rejected: NO_MATCH)
F6.1 Mixed admit + reject — admit wins (any_admitted gates the deny path) 1 admit with mutations
F6.2 All NO_MATCH and no admits 1 no_rule_matched
F7.1 Determinism — 10× run, deep-equal 4 (×10) identical results
F7.2 Determinism — different state but same registry 1 results stable per-state
F8.1 rule_version stamped on admit 1 result.rule_version === registry.computeVersionHash()
F8.2 rule_version stamped on policy/rule denial 1 same
F8.3 rule_version stamped on rule_version_mismatch denial uses registry’s view 1 expected = actual_registry; actual = req.rule_version
F9.1 inspectFunctionForbidden(evaluateAdmission) returns [] 1 clean
F9.2 inspectFunctionForbidden clean for scanRejections-via-evaluateAdmission body 1 clean (since scanRejections is internal, we scan via evaluateAdmission.toString() which inlines or references it)
F10.1 Total — engine throws are caught 1 rule_rejected with budget reason
F11.1 verifyRuleVersion re-export referential identity 1 expect(admission.verifyRuleVersion).toBe(versioning.verifyRuleVersion)
F12.1 mode propagation — actor record carries mode 1 (verified by a rule that reads $mode from event and admits/rejects on its value)
F12.2 tool propagation — event record carries tool 1 (same idea — rule reads $tool)

Total distinct test cases: F1×1 + F2×8 + F3×4 + F4×2 + F5×3 + F6×2 + F7×5 + F8×3 + F9×2 + F10×1 + F11×1 + F12×2 = 34 cases (some bundled into single tests). Comfortably exceeds the ≥ 20 acceptance threshold.

§P4.3. Edge-case κ rules used in fixtures

We need short, parseable κ source strings for each fixture. The κ DSL grammar (per parser.ts and policy-gate.ts §3) supports:

rule NAME { guards { <expr> -> admit | <expr> -> reject "REASON" | else -> ... } effects { <call(args)> ... } }

Sample fixtures (literal source we feed RuleRegistry.loadRuleset):

# F2.2 — admit, no effects.
rule SimpleAdmit { guards { true -> admit } effects { } }

# F2.3 — admit + emit.
rule AdmitEmit { guards { true -> admit } effects { emit("created", 1n) } }

# F2.4 — admit + set.
rule AdmitSet { guards { true -> admit } effects { set($x, 42n) } }

# F2.5 — multiple effects.
rule AdmitMulti { guards { true -> admit } effects { emit("a", 1n) emit("b", 2n) emit("c", 3n) } }

# F2.7 — explicit reject.
rule AlwaysReject { guards { true -> reject "no_reason" } effects { } }

# F5.3 — every guard false.
rule NoMatch { guards { false -> admit } effects { } }

# F12 — branch on mode.
rule ModeAdmit {
  guards { $event.mode == "normal" -> admit
           else -> reject "wrong_mode" }
  effects { }
}

We verify these parse + load successfully in the test suite (RuleRegistry.loadRuleset returns without throwing). If the κ parser doesn’t support a particular construct, we adjust the fixture. The test suite is robust against minor κ DSL surface variations because each fixture is a bespoke string.

§P4.4. Pre-existing fixture compatibility

The κ DSL exact surface matters. Before writing tests, the implementer should spot-check that RuleRegistry.loadRuleset('rule X { guards { true -> admit } effects { } }') works against R86 main. If a fixture string fails to parse, the implementer adjusts the test fixture (NOT the parser).

§P5. Determinism strategy

Per contract I9, inspectFunctionForbidden(evaluateAdmission) must return []. Forbidden tokens to avoid:

  • Math.* — none used.
  • Date.* / new Date — none used.
  • setTimeout / setInterval / setImmediate — none used.
  • fetch / XMLHttpRequest — none used.
  • crypto.* — none used (the createHash in versioning.ts is at versioning layer).
  • process.hrtime / process.nextTick — none used.
  • await / async function / async ( — none used.
  • Float literals — none used (we use 1n, 0n, 42n).
  • [native code] — N/A (we don’t use bound natives).

The test self-scans evaluateAdmission directly. Internal helpers that are not exported but are referenced inside evaluateAdmission will appear in the function’s source via lexical closure references — since both evaluateAdmission and scanRejections live in the same module, Function.prototype.toString() on evaluateAdmission may not include scanRejections’s body. To be safe, we scan both functions; we expose scanRejections only via an internal non-exported reference. We could add a test-only export, but to keep the public surface clean we skip that and trust the regex pattern’s behavior on inlined references.

Decision: the test asserts inspectFunctionForbidden(evaluateAdmission) is clean; it does NOT assert scanRejections directly because that function is internal. If scanRejections ever needs forbidden tokens (it shouldn’t), a test-only __internalScan export can be added without breaking the public surface.

§P6. Migration notes

  • DenialReason discriminants — the four kind values lock at this round. P1.4.2 may extend; tests forbid removal.
  • verifyRuleVersion re-export — referential identity is asserted in F11.1 to lock the no-shadowing invariant.

§P7. Test ordering

The test file follows the same describe('F<n> — <topic>') convention as the sibling test files (engine, registry, versioning, etc.). One describe per fixture family, multiple test blocks within. Run individually via npm test -- admission to iterate quickly during implementation.

§P8. Implementation checklist

  • src/domains/rules/admission.ts — module skeleton with §P2 imports, exports, function bodies.
  • src/domains/rules/admission.ts — type interfaces locked per §2.1.
  • src/domains/rules/admission.tsevaluateAdmission body per §P3.
  • src/domains/rules/admission.tsscanRejections helper per §P3.6.
  • src/domains/rules/admission.tsverifyRuleVersion re-export.
  • src/__tests__/domains/rules/admission.test.ts — F1–F12 fixtures.
  • npm run build — clean.
  • npm run lint — clean.
  • npm test — ≥ 1972 + (admission tests) all green.

§P9. Verification sketch (for Step 5)

The verification document (Step 5) reports:

  • Test counts (suites + tests).
  • Coverage from npm run test -- --coverage --testPathPattern=admission.
  • Determinism assertion summary.
  • Migration-discipline check (DenialReason discriminants intact).
  • Cross-reference table mapping contract invariants I1–I14 to specific test IDs that prove them.

§P10. Estimated effort

Per task prompt: L (1–2 days). Realistic breakdown:

  • Audit (Step 1): 30 min — done.
  • Contract (Step 2): 30 min — done.
  • Packet (Step 3): 30 min — this file.
  • Implementation (Step 4): 1–2 hours (mostly mechanical given the contract).
  • Tests (Step 4): 2–3 hours — most of the time.
  • Verification (Step 5): 30 min.
  • Gate (build + lint + test): 5 min.
  • Push + PR + merge: 15 min.

Total: ~5 hours of agent time in this single session.


Generated 2026-05-07 in feature/p1-4-1-admission. R87 κ Wave 6 — T3 executor chain Step 3/5. Implementation may begin after this packet is committed.


Back to top

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

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