P1.3.4 — κ Policy Gating / Pre-guards — Audit

Step 1 of the 5-step executor chain (audit → contract → packet → implement → verify). Inventory of the surface that src/domains/rules/policy-gate.ts will land on. Builds on the P1.2.2 parser (R84 7218b34b), the P1.3.1 evaluator (R85 d766db59), the P1.2.3 validator (R85 15955994), and the P1.5.4 canonical serialization (R85 799e70a9). Sibling parallel slices in R86: P1.2.4 registry (src/domains/rules/registry.ts), P1.3.2 builtins (src/domains/rules/builtins.ts), P1.3.3 state-access (src/domains/rules/state-access.ts), P1.5.1 versioning (src/domains/rules/versioning.ts).

§1. Surface inventory

§1.1. Target files (greenfield for this task)

Path Exists at base d766db59? Purpose
src/domains/rules/policy-gate.ts No P1–P13 policy table + check_policy + check_all_policies
src/__tests__/domains/rules/policy-gate.test.ts No Behavioral test suite for the above

The task prompt path src/domains/rules/__tests__/policy-gate.test.ts does not match the shipped layout. Tests live colocated under src/__tests__/domains/rules/ (CLAUDE.md §9.1 — “Jest test suite (colocated, not tests/)”). All sibling rule tests follow this pattern (bps-constants.test.ts, canonical.test.ts, determinism.test.ts, engine.test.ts, integer-math.test.ts, lexer.test.ts, parser.test.ts, validator.test.ts). This audit treats the colocated path as authoritative.

§1.2. Read-only dependencies (consumed)

Path Surface used Stability
src/domains/rules/parser.ts parse(input: string): ParseResult, types: Expression, RuleNode, ParseError shipped R84 (7218b34b); 11-node AST
src/domains/rules/engine.ts evaluateExpr(expr, ctx): bigint \| string \| boolean, types: Context, BudgetTracker, classes: RuleBudgetExceeded, constants: MAX_INTEGER_OPS, MAX_CALL_DEPTH, MAX_ARG_COUNT shipped R85 (d766db59); pure
src/domains/rules/integer-math.ts type-only — OverflowError, DivisionByZeroError re-thrown by evaluateExpr shipped R83
src/domains/rules/determinism.ts inspectFunctionForbidden, assertDeterministic (test-only consumption) shipped R83

Not depended on: state-access.ts (parallel sibling — not yet merged at base SHA). The contract treats actor + context as plain Readonly<Record<string, unknown>> shapes mirroring engine.Context.event / engine.Context.state, so policy-gate has zero coupling to whatever in-flight ReadOnlyState interface ships in P1.3.3.

§1.3. Public-surface inventory at base d766db59

src/domains/rules/engine.ts exports:

  • Types: Category, Mutation, BudgetTracker, Context, RuleResult, TransitionResult, CategorizedRule, RuleRegistry
  • Constants: MAX_INTEGER_OPS = 10_000, MAX_CALL_DEPTH = 16, MAX_ARG_COUNT = 8, CATEGORY_ORDER
  • Class: RuleBudgetExceeded
  • Functions: evaluate, evaluateExpr, executeRuleset

src/domains/rules/parser.ts exports (types relevant to policies):

  • Expression, RuleNode, ParseError, ParseResult, Location
  • parse, countNodes, MAX_AST_NODES_PER_RULE, MAX_PARSE_ERRORS

The policy-gate module touches none of these — it composes them. No file under src/ will be modified by this task. This is a strict-additive change.

§1.4. Test-suite inventory at base d766db59

src/__tests__/domains/rules/
├── bps-constants.test.ts
├── canonical.test.ts
├── determinism.test.ts
├── engine.test.ts
├── integer-math.test.ts
├── lexer.test.ts
├── parser.test.ts
└── validator.test.ts

Test count after R85 close: ~1658 (per memory). policy-gate tests are pure-additive — no existing test will be touched.

§2. Spec source — P1–P13 from extraction §9

docs/reference/extractions/kappa-rule-engine-extraction.md §9 (lines 303–330) declares the policy enum and the two functions. The extraction surface:

enum PolicyId: P1, P2, P3, P4, P5, P6, P7, P8, P9, P10, P11, P12, P13

struct Policy:
    id:         PolicyId
    predicate:  Expression    // DSL expression (pure, no side effects)
    rejection:  string        // reason string on failure

function check_policy(policy_id, actor, context) -> PolicyResult:
    policy = POLICIES[policy_id]
    ctx = context.with_binding("actor", actor)
    if not evaluate_expr(policy.predicate, ctx):
        return PolicyResult { admitted: false, reason: policy.rejection }
    return PolicyResult { admitted: true }

function check_all_policies(action_name, actor, context) -> PolicyResult:
    applicable = get_applicable_policies(action_name)
    for policy_id in applicable:
        result = check_policy(policy_id, actor, context)
        if not result.admitted:
            return result    // first policy failure short-circuits
    return PolicyResult { admitted: true }

The extraction does not define semantics for individual P1–P13 predicates (those land per future κ rounds). It only fixes the enum cardinality and the two function signatures. The task prompt confirms this — stub predicates default-admit (e.g. "true") so that admission flow tests upstream don’t deny everything at boot.

§3. Drift / mismatch findings

ID Finding Severity Resolution
D1 Task prompt path src/domains/rules/__tests__/policy-gate.test.ts does not match the shipped layout (tests are colocated at src/__tests__/domains/rules/). Low Use the shipped path. Document in audit §1.1.
D2 Extraction §9 references context.with_binding("actor", actor) — no such method exists on engine.Context. The bindings: ReadonlyMap<string, ...> field is the equivalent shape. Low Implement withActorBinding(context, actor) as a pure helper that returns a fresh Context with bindings = new Map([['actor', <encoded actor>]]). Single-segment $actor lookups in the predicate then resolve via the existing resolveVarRef path.
D3 Extraction §9 get_applicable_policies(action_name) is referenced but undefined in the extraction. Low Each policy carries applicable_actions: string[]. check_all_policies filters POLICIES by membership. Empty array means “applies to no action” (effectively disabled). ["*"] is a sentinel for “applies to every action” and is used by all stub policies.
D4 Engine’s freshBudget() is module-private. policy-gate cannot reuse it — must construct a BudgetTracker literal {integer_ops: 0, call_depth: 0, current_arg_count: 0}. Low Inline the literal. Engine’s BudgetTracker is a plain interface; constructing one is type-safe.
D5 evaluate_expr in pseudocode is the engine’s evaluateExpr. The pseudocode’s “evaluate to truthy” semantics must be tightened — engine evaluates expressions to bigint \| string \| boolean. Only true admits; everything else (false, any bigint, any string) → reject with a typed reason. Medium Implement a strict === true guard in check_policy. Non-boolean predicate values map to {admitted: false, reason: "POLICY_TYPE_MISMATCH"}.
D6 Engine’s evaluateExpr throws on budget exceedance, undefined variables, type mismatches, overflow, divide-by-zero. The task prompt prescribes {admitted: false, reason: "POLICY_EVAL_ERROR"} for any thrown error. Low Wrap the call in try/catch. Translate any thrown error to POLICY_EVAL_ERROR. Specific error subkinds are not surfaced — the engine already emits typed reasons inside thrown Error.message, which would leak engine-internal details into policy output if propagated. The opaque POLICY_EVAL_ERROR is the deliberate boundary.

§4. Forbidden / out-of-scope

  • Do not modify parser.ts, engine.ts, validator.ts, canonical.ts, integer-math.ts, bps-constants.ts, determinism.ts, lexer.ts. These are R83/R84/R85 shipped modules — strict-additive boundary.
  • Do not write a separate evaluator. Reuse engine.evaluateExpr end-to-end. The contract’s behavioral test suite asserts this via determinism harness self-scan.
  • Do not surface dynamic rejection strings. Each policy’s rejection_reason is a static string, fixed at module-init time. Per task prompt: “no dynamic rejection reasons”.
  • Do not bind state-access.ts. It is a parallel sibling slice; the contract uses plain Readonly<Record<string, unknown>> for actor/context shapes. Coupling is zero.
  • Do not add MCP tools. The κ surface ships no MCP tools at Phase 1 (the 14-tool Phase 0 surface is locked). policy-gate is a library-only module.

§5. Test gate

Per CLAUDE.md §5: all three of npm run build && npm run lint && npm test must pass. Test count is expected to grow by 30–60 cases (acceptance criteria + extraction-defined invariants).

§6. Determinism guardrails

policy-gate.ts is consumed during deterministic admission flow (P1.4.1). It must satisfy:

  • No Math.*, no Date.*, no setTimeout/setInterval/setImmediate, no fetch, no crypto.*, no process.hrtime/nextTick, no await, no async, no float literals.
  • Pure: no I/O, no DB, no network, no env reads, no console output.
  • Idempotent: same (POLICIES, action, actor, context) → same PolicyResult.

The test suite asserts this via inspectFunctionForbidden(check_policy) and inspectFunctionForbidden(check_all_policies) (mirrors engine.test.ts F7).

§7. Out-of-scope / deferred

  • Substantive predicate semantics for individual P1–P13 (deferred — every stub admits via "true").
  • Integration with admission flow (P1.4.1 next wave wires check_all_policies into the named-rule pipeline).
  • Performance budget for full P1–P13 sweep (no cap is contracted at this slice; the caller’s per-action overall budget is the concern of P1.4.1).
  • Persistence / configuration of the POLICIES table (frozen module-init constant for now; admin tooling lands in a later phase).

Step 1 of 5 complete. Step 2 (contract) defines the precise public-surface signatures; Step 3 (packet) sequences the implementation.


Back to top

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

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