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
- Audit:
../audits/p1-4-1-admission-audit.md - Contract:
../contracts/p1-4-1-admission-contract.md - Task prompt:
../guides/implementation/task-prompts/p1.1-kappa-rule-engine.md§P1.4.1 - Spec:
../spec/s10-admission.md - Concept:
../3-world/physics/laws/rule-engine.md§Admission layer - Extraction:
../reference/extractions/kappa-rule-engine-extraction.md§8 + §9
§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 (thecreateHashinversioning.tsis 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
DenialReasondiscriminants — the fourkindvalues lock at this round. P1.4.2 may extend; tests forbid removal.verifyRuleVersionre-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.ts—evaluateAdmissionbody per §P3.src/domains/rules/admission.ts—scanRejectionshelper per §P3.6.src/domains/rules/admission.ts—verifyRuleVersionre-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.