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.tswill land on. Builds on the P1.2.2 parser (R847218b34b), the P1.3.1 evaluator (R85d766db59), the P1.2.3 validator (R8515955994), and the P1.5.4 canonical serialization (R85799e70a9). 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,Locationparse,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.evaluateExprend-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_reasonis 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.*, noDate.*, nosetTimeout/setInterval/setImmediate, nofetch, nocrypto.*, noprocess.hrtime/nextTick, noawait, noasync, no float literals. - Pure: no I/O, no DB, no network, no env reads, no console output.
- Idempotent: same
(POLICIES, action, actor, context)→ samePolicyResult.
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_policiesinto 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.