P1.4.1 — Admission Evaluator — Behavioral Contract (Step 2 / 5)

The implementation cannot proceed without an approved contract. This file locks the public surface, the algorithm, the error model, the determinism guarantees, and the migration discipline. Step 4 (implementation) MUST honor every clause here. Step 5 (verification) tests against this contract, not against the implementation.

§1. Scope

P1.4.1 ships src/domains/rules/admission.ts — a pure module exposing evaluateAdmission, the AdmissionRequest / AdmissionResult / DenialReason types, and a re-export of verifyRuleVersion.

In scope:

  • Composing P1.5.1 version check + P1.3.4 policy gate + P1.3.1 named-rule evaluation into a single deterministic verdict function.
  • The AdmissionResult discriminated union with rule_version stamping on every result.
  • Constant-time rule-version comparison (re-exported from P1.5.1).
  • ≥ 20 representative tuple tests over (caller, tool, mode) combinations.

Out of scope (deferred):

  • The expanded DenialReason taxonomy with policy-id structuring → P1.4.2.
  • Mutation application and conflict detection → β commit-time concern.
  • Audit-trail emission for admission verdicts → ζ integration follow-up.
  • VRF audit selection (5%–20% deeper-verification sampling per S10) → later κ phase.
  • Live MCP wiring of admission into tool-lock → α follow-up.

§2. Public surface (locked)

The exported names below are the entire public surface. Any addition is a contract amendment; any rename is a breaking change requiring a major-bump discussion.

§2.1. Types

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

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

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 };

Mutation and ReadOnlyState are imported (not re-declared) from ./engine.js and ./state-access.js.

§2.2. Functions

export function evaluateAdmission(
  req: AdmissionRequest,
  registry: RuleRegistry,
): AdmissionResult;

// Re-export for callers that want to compare two rule_version strings without
// taking a separate dependency on versioning.ts.
export { verifyRuleVersion } from './versioning.js';

RuleRegistry is the registry.ts class (not the engine’s smaller interface). The implementation depends on registry.computeVersionHash() which the smaller engine.ts interface does not declare.

§2.3. Error model

evaluateAdmission is total — it never throws. Any internal exception from the engine or policy gate is converted into an AdmissionResult with admitted: false. The four DenialReason discriminants cover every failure path:

Discriminant Trigger
rule_version_mismatch verifyRuleVersion(req.rule_version, registry.computeVersionHash()) returned false.
policy check_all_policies(...) returned {admitted: false, reason: ...}. The policy_reason field carries the verbatim P1.3.4 sentinel (e.g. 'P1_NOT_AUTHORIZED', 'POLICY_TYPE_MISMATCH', 'POLICY_EVAL_ERROR').
rule_rejected The engine’s executeRuleset populated per_category_results with at least one {status: 'rejected'} entry across all categories. We surface the first rejection in iteration order (CATEGORY_ORDER, then alpha within category). The rule_name is the rejecting rule’s name; rule_reason is the engine’s reason string ('NO_MATCH', 'budget:integer_ops', explicit reject "...", or one of the typed engine messages).
no_rule_matched All Admission-category rules returned {status: 'rejected', reason: 'NO_MATCH'} AND no other category rejected. Default-deny.

If the registry is empty (no rules at all), evaluateAdmission returns {admitted: false, reason: {kind: 'no_rule_matched'}, rule_version}. The empty-product is deny, never admit.

§3. Algorithm (locked)

evaluateAdmission(req, registry):
  v_actual   = registry.computeVersionHash()        # reads registry once per call
  v_expected = req.rule_version

  # (1) Rule-version check FIRST. Constant-time. No data-dependent branch
  #     before this — see §6 timing-independence guarantee.
  if not verifyRuleVersion(v_expected, v_actual):
    return {
      admitted: false,
      reason: { kind: 'rule_version_mismatch', expected: v_actual, actual: v_expected },
      rule_version: v_actual,
    }

  # (2) Build the engine-facing event + state once.
  state_record = req.rep_snapshot.toEngineState()         # 7 keys, plain record
  actor_record = { id: req.caller, mode: req.mode }       # mode propagated for policy access
  event_record = { actor: req.caller, tool: req.tool, mode: req.mode }

  # (3) Policy pre-guards. Short-circuit on first failure.
  policy_result = check_all_policies(req.tool, actor_record, state_record)
  if not policy_result.admitted:
    return {
      admitted: false,
      reason: { kind: 'policy', policy_reason: policy_result.reason },
      rule_version: v_actual,
    }

  # (4) Named-rule evaluation. Engine handles category iteration + alpha sort.
  transition = executeRuleset(registry, event_record, state_record, v_actual, req.rep_snapshot.epoch)

  # (5) Disposition.
  #     (a) Surface FIRST rejection in iteration order (CATEGORY_ORDER, alpha within).
  #     (b) Otherwise, if at least one rule admitted, return all_mutations.
  #     (c) Otherwise (every rule rejected NO_MATCH / no rule existed), return no_rule_matched.
  rejection = first_rejection_in_iteration_order(transition.per_category_results, registry)
  any_admitted = transition.all_mutations.length > 0 OR
                 any_per_category_status_admitted(transition.per_category_results)

  if rejection != null and not any_admitted:
    if rejection.is_no_match_only:
      return { admitted: false, reason: { kind: 'no_rule_matched' }, rule_version: v_actual }
    return {
      admitted: false,
      reason: { kind: 'rule_rejected', rule_name: rejection.name, rule_reason: rejection.reason },
      rule_version: v_actual,
    }

  return {
    admitted: true,
    effect_mutations: transition.all_mutations,
    rule_version: v_actual,
  }

§3.1. “First rejection in iteration order”

Iteration order is CATEGORY_ORDER from engine.ts (Admission → StateTransition → Consequence → Promotion). Within a category, the engine already sorts alpha. To recover the rule name for a rejection, we walk the registry’s getAll() array filtered by category in the same order the engine walked it, and pair each RuleResult index with its RuleNode.name. This re-walk is purely a metadata projection — no re-evaluation.

§3.2. “is_no_match_only”

If every rejection across every category has reason === 'NO_MATCH', we collapse to no_rule_matched (cleaner signal for callers; “no admission rule applied to this tool” is a different class of denial than “a guard explicitly rejected”).

If at least one rule rejected for a non-'NO_MATCH' reason (e.g. a guard’s reject "needs_admin" clause, a budget overrun, a type mismatch), we surface that as rule_rejected with the rule name + reason.

§3.3. “any_admitted”

A rule is “admitted” iff its result is {status: 'admitted', mutations} — even with empty mutations. Some Admission-category rules may admit with no mutations (pure permission checks); this still counts as an admission.

§4. Invariants (asserted in tests)

ID Invariant
I1 evaluateAdmission is pure: no I/O, no DB, no fs, no network, no time, no RNG, no async.
I2 evaluateAdmission is deterministic: same (req, registry) → identical AdmissionResult (deep-equal).
I3 evaluateAdmission is total: no thrown exception escapes the function.
I4 Every AdmissionResult carries rule_version (the registry’s view, not the request’s, even on mismatch denials).
I5 Rule-version check fires before any policy or rule evaluation. Strictly: when version mismatch, no check_all_policies or executeRuleset call happens. (Verified by spying.)
I6 Policy pre-guards run before named-rule evaluation. When a policy denies, no executeRuleset call happens.
I7 verifyRuleVersion is the only path used for version comparison. The implementation does not contain === between two rule_version strings.
I8 The four DenialReason discriminants are the only kind values produced. (Type-level guarantee + runtime exhaustiveness check.)
I9 inspectFunctionForbidden(evaluateAdmission) returns [].
I10 evaluateAdmission accepts the registry.ts RuleRegistry class — interface narrowing lives at the call site, not in admission’s signature.
I11 The empty-registry product is no_rule_matched (deny). The contract forbids “admit-by-default-when-empty”.
I12 Mutations array is always a fresh array on each call (no aliasing of the engine’s internal array). Callers may store the result without aliasing concerns.
I13 mode is propagated into event_record.mode, actor_record.mode, and is not consulted by admission directly (no if mode === 'admin' short-circuit) — admin-mode override is encoded in the policy/rule layer.
I14 request.rep_snapshot is consulted only via toEngineState() and epoch — admission never touches the binding chain or the read-only Maps directly.

§5. Determinism

All composed components are deterministic:

  • verifyRuleVersion — pure string compare in constant time (P1.5.1 §8).
  • check_all_policies — pure (P1.3.4 §6 + §7).
  • executeRuleset — pure (P1.3.1 §5).
  • registry.computeVersionHash() — pure (P1.2.4 §5; in R86 it returns a stub string derived from rule count; in a future patch via wireVersionHash, it returns the real hash from versioning.ts. Both are deterministic).

Admission’s composition layer adds:

  • Two object literal allocations per call (actor_record, event_record).
  • One projection (state.toEngineState()).
  • One iteration-order walk (§3.1) — uses CATEGORY_ORDER literal + the registry’s stable getAll() order.

No data-dependent branches before the version check; no Map / Set iteration that could be insertion-order-sensitive (we use array indexing).

Verification: §F7 in the packet runs each fixture 10× and asserts deep-equal results.

§6. Timing independence

The acceptance criterion “Constant-time comparison helper for rule-version strings” is delivered by re-exporting verifyRuleVersion from P1.5.1, which provides a hand-rolled constant-time compare (loop length = max(expLen, actLen); no early exit on first differing byte).

Admission’s structural timing is not a constant-time black box — that’s a non-goal. The contract guarantees:

  • The version check runs FIRST. Until it passes, no policy / rule evaluation happens. Therefore no policy / rule timing data leaks for callers with a bad version.
  • After version passes, policy evaluation runs in POLICY_ORDER (P1.3.4). A bad policy halts the chain; subsequent policies’ evaluation timing is not observable.
  • After all policies admit, named-rule evaluation runs. A rule rejection surfaces rule_rejected; the timing reflects how many rules ran before the rejection.

The non-goals are explicitly documented to forestall future “but admission isn’t constant-time end-to-end” reviews. Per the task prompt’s acceptance criterion, the rule-version comparison is the constant-time portion; admission’s overall timing reflects its decision-tree depth.

§7. Test contract

The test suite (src/__tests__/domains/rules/admission.test.ts) MUST cover:

  • §F1 — Module shape (exports, type discriminants exist).
  • §F2 — 20+ representative (caller, tool, mode) tuple verdicts.
  • §F3 — Rule-version mismatch returns the typed denial with both expected and actual, and no policy/rule evaluation occurred (verified via test-side spying or constructed failing policy table).
  • §F4 — Policy denial returns the policy’s verbatim reason; engine never ran (verified via empty registry that would otherwise no-rule-match).
  • §F5 — Rule rejection returns the first rule’s name + reason in iteration order across categories.
  • §F6 — no_rule_matched triggers when every rule’s reason is 'NO_MATCH'.
  • §F7 — Determinism: 10× evaluation of N fixtures returns deep-equal results.
  • §F8 — Empty registry returns no_rule_matched.
  • §F9 — Pure-function self-scan: inspectFunctionForbidden(evaluateAdmission) returns [].
  • §F10 — Total: no thrown exception escapes (verified via fixtures designed to trigger engine errors and observing they map to rule_rejected).
  • §F11 — verifyRuleVersion is re-exported and identical to the versioning.ts export (referential identity).

≥ 20 distinct test cases overall. Coverage target: ≥ 95% lines, ≥ 90% branches on admission.ts.

§8. Migration discipline

P1.4.2 (Denial Reason Taxonomy) will widen the DenialReason union. P1.4.1 locks four discriminants — these MUST remain. P1.4.2 may add new discriminants, add fields to existing discriminants (additive), but may not rename or re-discriminate.

If a future round needs admission to emit an audit-trail record (ζ integration), that wiring lives at the call site in α tool-lock, not inside admission. Admission stays pure.

§9. Out-of-band

The audit (Step 1) flagged that registry.computeVersionHash() returns a stub ('sha256:stub:' + size + 'n') until wireVersionHash (P1.5.1 follow-up integration) lands. Admission honors whatever the registry returns — when the wire-up patches the registry’s computeVersionHash, admission inherits the real hash without code changes here. This is the design seam.

§10. Approval

This contract is approved at this round (R87 κ Wave 6) by the executor. Any contract violation in Step 4 (implementation) MUST be flagged in Step 5 (verification) and resolved before merge. The packet (Step 3) and the verification (Step 5) reference this contract by section number.


Generated 2026-05-07 in feature/p1-4-1-admission. R87 κ Wave 6 — T3 executor chain Step 2/5.


Back to top

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

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