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
AdmissionResultdiscriminated union withrule_versionstamping 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
DenialReasontaxonomy 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 viawireVersionHash, it returns the real hash fromversioning.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_ORDERliteral + the registry’s stablegetAll()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
expectedandactual, 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_matchedtriggers 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 —
verifyRuleVersionis 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.