P1.4.1 — Admission Evaluator — Audit (Step 1 / 5)
Inventory of the existing κ rule-engine surface that P1.4.1 composes into the single admission verdict function. This audit is read-only — no code changes are made here. The downstream contract (Step 2) and packet (Step 3) lock the consequences of these findings.
§1. Task framing
The admission evaluator is the κ entry-point that α’s tool-lock middleware
calls before any tool handler runs. It answers a single question:
Given a
(caller, tool, mode, rep_snapshot, rule_version)tuple — is this action admitted, and if so what mutations does it imply?
It composes three already-shipped slices into one:
- Constant-time rule-version check — comparing
req.rule_versionagainstregistry.computeVersionHash()via P1.5.1’sverifyRuleVersion. - P1–P13 policy pre-guards —
check_all_policies(...)from P1.3.4. - Named-rule evaluation — registry-driven, executed by the P1.3.1 engine
in
Admission → StateTransition → Consequence → Promotionorder.
Out-of-scope explicitly:
- DB writes / state application — admission collects the mutation list and hands it back. β (task pipeline) commits it at task-update time.
- Network / I/O of any kind — admission is a pure function (contract I1).
- Mutation conflict detection across rules — that is a downstream β concern; here we only return the flat list.
- Denial taxonomy refinement — the typed
DenialReasonunion is P1.4.2’s scope. P1.4.1 emits a coarse-grained shape that P1.4.2 can refine without breaking the public surface (see §6 below for the migration discipline).
§2. Existing surface — what already ships
§2.1. src/domains/rules/engine.ts (P1.3.1)
Pure AST evaluator. Exports executeRuleset(registry, event, state, rule_version, epoch): TransitionResult — the orchestrator we call after policy pre-guards pass. Returns {all_mutations, per_category_results}. Per-rule budget tracker is fresh on every iteration (extraction §5). The evaluator never mutates event / state; the only side-effect is counter increments on context.budget.
Relevant exports (consumed by P1.4.1):
| Export | Kind | Use in admission |
|---|---|---|
executeRuleset |
function | Run all named rules after policies admit. |
Mutation |
interface | Return shape for admitted result. |
TransitionResult |
interface | Internal — projected to effect_mutations. |
RuleRegistry |
interface | Engine’s view of the registry; admission consumes the richer P1.2.4 class. |
The engine’s executeRuleset signature is fixed; admission constructs event / state records that match what extraction §8 + §10 prescribe (event.actor carries the caller, event.tool carries the tool name, event.mode carries the mode; state carries the read-only snapshot).
§2.2. src/domains/rules/policy-gate.ts (P1.3.4)
P1–P13 pre-guards. The exported entry point check_all_policies(action_name, actor, context): PolicyResult is exactly what extraction §9’s process_action calls before named-rule dispatch. Iteration order is POLICY_ORDER (P1..P13 declaration order, not lexicographic). First non-admitted result short-circuits — remaining policies are not evaluated.
Discriminated union:
type PolicyResult =
| { readonly admitted: true }
| { readonly admitted: false; readonly reason: string };
Sentinel reasons (contract §4 of P1.3.4):
'POLICY_TYPE_MISMATCH'— predicate evaluated to bigint or string instead of bool.'POLICY_EVAL_ERROR'— predicate threw any kind of evaluator error (engine errors are NOT propagated; the boundary is opaque per P1.3.4 contract §4).- Per-policy
rejection_reason— e.g.'P1_NOT_AUTHORIZED','P6_RULE_VERSION_MISMATCH'.
These pass through into admission’s reason field; admission does not invent new sentinels for policy-side denials.
§2.3. src/domains/rules/registry.ts (P1.2.4)
RuleRegistry.loadRuleset(source: string): RuleRegistry is the canonical
constructor. The instance exposes:
| Method | Signature | Used by P1.4.1 |
|---|---|---|
getAll() |
readonly CategorizedRule[] |
Yes — passed verbatim to executeRuleset. |
getRule(name) |
RuleNode \| null |
Optional — admission may want to log “no matching rule” denials. |
getByTransitionType(t) |
readonly RuleNode[] |
Not required by P1.4.1 (engine handles category iteration). |
computeVersionHash() |
string |
Yes — the rule-version check compares req.rule_version to this. Pre-R86 stub ('sha256:stub:' + size + 'n'); R86 P1.5.1 ships a real SHA-256 hash in versioning.ts. The registry’s stub is kept by design — wireVersionHash(registry) is the planned integration patch. P1.4.1 calls registry.computeVersionHash() at the seam — when the wire-up lands, admission inherits the real hash without any code change here. |
§2.4. src/domains/rules/state-access.ts (P1.3.3)
makeReadOnlyState(init): ReadOnlyState — the read-only state snapshot the engine sees. Carries epoch, event_count, fork_id, rule_version, plus three Maps (reputation, tokens, stakes) and a binding chain. with_binding(name, value) is O(1) parent-pointer.
Crucially, the type is ReadOnlyState, not the Readonly<Record<string, unknown>> that the engine’s Context.state expects. The bridge is state.toEngineState() which projects the 7 keys plus serialized Maps into a flat record.
The admission request carries a rep_snapshot: ReadOnlyState — the host pre-builds it from DB state before calling admission.
§2.5. src/domains/rules/versioning.ts (P1.5.1)
verifyRuleVersion(expected: string, actual: string): boolean — the constant-time string comparison helper. Loop length is max(expLen, actLen); accumulator carries the length-equality bit. Re-exported by admission per the task prompt’s acceptance criterion (“Exports the constant-time comparison helper for rule-version strings”).
computeVersionHash(ruleset, engine_version) — the standalone hash function over a sorted, location-stripped, canonicalized ruleset. P1.4.1 does not invoke this directly; it consults registry.computeVersionHash() so a future wireVersionHash patch flows through transparently.
§2.6. src/domains/rules/determinism.ts (P1.1.2)
inspectFunctionForbidden(fn): readonly string[] — the regex-based forbidden-token scanner. P1.4.1 must produce [] for every exported function body. This is asserted directly in the test suite (acceptance criterion: “Determinism scanner clean”).
§3. Admission flow — pseudocode trace
The task prompt §P1.4.1 fixes the algorithm. Translated literally:
evaluateAdmission(req, registry):
// (1) Rule-version check FIRST. Per common gotcha:
// never run policies before version check, because policy-eval timing
// leaks via a bad version.
if not verifyRuleVersion(req.rule_version, registry.computeVersionHash()):
return { admitted: false,
reason: { kind: "rule_version_mismatch",
expected: registry.computeVersionHash(),
actual: req.rule_version },
rule_version: registry.computeVersionHash() }
// (2) Policy pre-guards. Action_name = req.tool; actor = { id: req.caller, mode: req.mode }.
policy_result = check_all_policies(req.tool, { id: req.caller, mode: req.mode }, req.rep_snapshot.toEngineState())
if not policy_result.admitted:
return { admitted: false,
reason: { kind: "policy", policy_reason: policy_result.reason },
rule_version: registry.computeVersionHash() }
// (3) Named-rule evaluation. Engine handles Admission → StateTransition →
// Consequence → Promotion ordering and per-category alpha sort.
event = { actor: req.caller, tool: req.tool, mode: req.mode }
state = req.rep_snapshot.toEngineState()
result = executeRuleset(registry, event, state, registry.computeVersionHash(), req.rep_snapshot.epoch)
// (4) If any rejection landed in the per-category map, surface it.
// If no Admission-category rule fired (no rule had the tool's transition type
// and admitted), treat as deny(no_rule_matched).
... (see contract §3 + packet §3 for the exact dispatch logic) ...
// (5) Otherwise admit.
return { admitted: true, effect_mutations: result.all_mutations, rule_version: registry.computeVersionHash() }
The contract (Step 2) locks (a) the exact disposition of “no rule matched” vs “a rule rejected” and (b) the rep_snapshot → engine.Context.state projection. The packet (Step 3) lays out the test-fixture taxonomy.
§4. Type signatures we will export
export interface AdmissionRequest {
readonly caller: string;
readonly tool: string;
readonly mode: 'normal' | 'readonly' | 'admin';
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_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 };
export function evaluateAdmission(req: AdmissionRequest, registry: RuleRegistry): AdmissionResult;
export { verifyRuleVersion } from './versioning.js';
The DenialReason shape is the migration seam for P1.4.2: P1.4.2 will widen the union with additional discriminants (e.g. integrity dampened, fork-mismatch). The four kinds above are the minimal coverage of what evaluateAdmission itself can produce; P1.4.2 widens for downstream consumers without breaking the existing four discriminants.
§5. Forbidden patterns inventory
P1.4.1’s source must be clean against determinism.ts §FORBIDDEN_PATTERNS:
| Pattern | Risk surface in admission | Mitigation |
|---|---|---|
Math.* |
None — no arithmetic beyond what the engine already handles. | None needed. |
Date.* / new Date |
None — admission is timeless from κ’s view (epoch is in state.epoch). |
None needed. |
setTimeout / setInterval / setImmediate |
None. | None needed. |
fetch / XMLHttpRequest |
Admission is pure — no network. | None needed. |
crypto.<member> |
None — version comparison goes through verifyRuleVersion, which already imported createHash in versioning.ts. Admission itself never touches crypto. |
None needed. |
process.hrtime / nextTick |
None. | None needed. |
await / async |
Admission is sync. executeRuleset is sync. check_all_policies is sync. |
None needed. |
| Float literal | None. | None needed. |
[native code] |
Only via inspect-self in tests; never at runtime in admission. | None needed. |
Test assertion: inspectFunctionForbidden(evaluateAdmission) returns [].
§6. Migration discipline — what P1.4.2 will (and won’t) change
P1.4.2 is “Denial Reason Taxonomy”. The contract in this audit names the four discriminants P1.4.1 emits. P1.4.2 must not remove or rename any of them; it may add new discriminants for downstream (β tool-lock logging, audit trail classification). P1.4.1’s tests assert the four discriminants exist with exactly the field names declared in §4.
P1.4.1 also does not paint the DenialReason carrier-string semantics — e.g.
the policy_reason: string field carries the verbatim P1.3.4 sentinel
('P1_NOT_AUTHORIZED', etc.) without remapping. P1.4.2 may add a structured
mapping (e.g. { policy_id: 'P1', policy_kind: 'authorization' }) but the
string carrier stays as the lossless fallback.
§7. Determinism contract
Per the task prompt’s “Determinism: identical input → bit-identical output”:
- The engine is deterministic by construction (§2.1).
check_all_policiesis deterministic by construction (§2.2).verifyRuleVersionis deterministic by construction (§2.5).registry.computeVersionHash()is deterministic (stub is constant; real impl is content-derived).- Admission’s only composition layer is the dispatch decision tree. As long as branch order is fixed and we don’t introduce non-deterministic helpers, the output is bit-identical.
Test: re-run evaluateAdmission 10× over the same (req, registry) pair, assert the result object is === (reference-stable) or at least deeply equal. We will use deep-equal — admission allocates fresh result objects per call (memo would be premature, since test parity is what matters).
§8. Test-fixture taxonomy preview
The packet (Step 3) locks the exact list, but the audit names the families to make sure they all map to existing fixtures or are simple to author:
| Family | Count target | Notes |
|---|---|---|
(alice, create_task, normal) admit + mutations |
4–5 | Vary effects: 0, 1, 2, max-cap. |
(bob, arbitrate, normal) policy denial |
2–3 | Different policy ids (P1, P6, P9) once stub permissive baseline shipped allows simulating denials. |
(admin, delete_all, admin) admit |
1–2 | Admin mode short-circuit. |
(alice, create_task, normal, rule_version=stale) rule-version mismatch |
2 | Stale and empty-string rule_version. |
(alice, unknown_tool, normal) no_rule_matched |
2 | Ensures we don’t accidentally admit-by-default. |
(alice, multiple_rules, normal) rule rejection |
2 | Engine returns a rejection in per_category_results — admission must surface it as 'rule_rejected'. |
| Determinism (run-N self-equality) | 4 | Random-but-fixed (req, registry) pairs run 10× each. |
inspectFunctionForbidden clean |
1 | Asserted on evaluateAdmission body. |
≥ 20 distinct cases — comfortably exceeds the acceptance threshold.
§9. Risk register
| Risk | Likelihood | Mitigation |
|---|---|---|
Engine’s event / state shape not what extraction §8 implies |
Low | Audit confirmed event.actor, state.<top-level>. State projection via toEngineState is canonical. |
executeRuleset admits-by-default when no rule matches |
High — must verify | Engine’s evaluate returns {status: 'rejected', reason: 'NO_MATCH'} if no guard matched. So executeRuleset populates per_category_results with rejections; admission must explicitly check that at least one Admission-category rule admitted. Otherwise our composition admits-by-default. |
| Constant-time helper not actually constant-time | Low | P1.5.1 already ships verifyRuleVersion with explicit constant-time guarantee + tests. We re-export, not re-implement. |
DenialReason.kind: 'rule_version_mismatch' leaks expected hash |
Medium — info disclosure | Audit decision: include expected only in development mode? Locked in contract §3 — for Phase 1 we include both expected and actual (debug-grade visibility is more important than a half-measure leak; the rule-version isn’t a secret, it’s a public consensus parameter). P1.4.2 may revise. |
| Rep snapshot mode-vs-mode confusion | Low | The task prompt is explicit: mode: "normal" | "readonly" | "admin". No mapping needed. |
| Sub-agent dispatch via Task | N/A | Phase 0 dispatch — host Task tool only. Not relevant to this slice. |
§10. Conclusion
All upstream κ slices ship. The composition is mechanical: re-export
verifyRuleVersion, call check_all_policies, call executeRuleset, and
project results into AdmissionResult. The only non-trivial decision is the
“no-rule-matched vs rule-rejected” disposition, locked in §3 + the contract.
Proceed to Step 2.
Generated 2026-05-07 in feature/p1-4-1-admission. R87 κ Wave 6 — T3 executor chain Step 1/5.