P1.3.4 — κ Policy Gating / Pre-guards — Verification
Step 5 of the 5-step executor chain. Records test evidence for the implementation in
src/domains/rules/policy-gate.ts+src/__tests__/domains/rules/policy-gate.test.ts. Closes the chain.
§V1. Test gate — npm run build && npm run lint && npm test
All three gates green at the implement-step commit (37ffc3be):
| Gate | Result | Notes |
|---|---|---|
npm run build |
✅ exit 0 | TypeScript clean — no tsc errors. postbuild ran scripts/copy-migrations.mjs; 6 migrations copied. |
npm run lint |
✅ exit 0 | ESLint clean — zero warnings, zero errors across src/. |
npm test |
✅ 36 suites, 1716 tests passing | Baseline 1658 (R85 close) + 58 new policy-gate cases. Zero regressions. |
Notes on flakiness
src/__tests__/server.test.tsfailed once during the full-suite run (the pre-existingstartup — subprocess smokepattern documented in memory: “all 4 R77 executors hit it once, always green on rerun”). It passed on re-run and on isolated invocation. Not a policy-gate regression.
§V2. Acceptance-criteria mapping
The 7 acceptance bullets from docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.3.4:
| ID | Criterion | Status | Evidence |
|---|---|---|---|
| A1 | Policy enum P1–P13 per extraction §9 | ✅ | PolicyId enum at policy-gate.ts §1 (string-valued, P1..P13). F1.1 asserts cardinality. |
| A2 | Each policy is a pure DSL expression (reuses P1.2.2 parser + P1.3.1 evaluator) | ✅ | parsePredicate calls parse from ./parser.js; check_policy_with_table calls evaluateExpr from ./engine.js. F10.5 asserts the import statement is present. F10.6 asserts no local AST switch (re-implementation). |
| A3 | check_policy(id, actor, context) → {admitted, reason?} |
✅ | policy-gate.ts §6, signature exact. F2.1–F2.4 cover totality. |
| A4 | check_all_policies(action, actor, context) short-circuits on first failure |
✅ | policy-gate.ts §7. F3.1, F3.6, F7.3 directly assert short-circuit. F3.6 specifically asserts “3rd fails → 4..13 not evaluated” — the brief’s headline. |
| A5 | Policies run BEFORE named rule evaluation | ✅ | Module is library-only (no MCP tools, no admission-flow integration in this slice). The “before” ordering is enforced by the consumer (P1.4.1). policy-gate’s contract makes this explicit (contract §1 + §9). |
| A6 | Policies share evaluation budget with named rules (same 10k op cap) | ✅ | check_policy_with_table uses evaluateExpr which consumes MAX_INTEGER_OPS = 10_000 from engine.ts. F9.5 builds a 10001-node nested AST and confirms POLICY_EVAL_ERROR (the budget exceedance translation). |
| A7 | Each policy has rejection reason pre-registered (no dynamic strings) | ✅ | All 13 stubs have static rejection_reason strings (P1_NOT_AUTHORIZED, etc.). F1.3 asserts non-empty, F1.4 asserts distinctness, F7.1/F7.4 assert verbatim preservation. The contract §4 forbids dynamic reasons; the implementation’s check_policy_with_table returns the literal policy.rejection_reason field unchanged. |
All seven acceptance criteria met.
§V3. Contract-invariant mapping
15 invariants from docs/contracts/p1-3-4-policy-gate-contract.md §3:
| ID | Invariant | Test class | Status |
|---|---|---|---|
| I1 | All 13 PolicyIds in POLICIES at module load. | F1.1 | ✅ |
| I2 | Every Policy.predicate_ast is a valid Expression. | F1.2 | ✅ |
| I3 | Every Policy.rejection_reason is non-empty. | F1.3 | ✅ |
| I4 | check_policy is total — never throws. | F2.2 | ✅ |
| I5 | check_all_policies short-circuits — later policies not evaluated. | F3.1, F3.6 | ✅ |
| I6 | Returns {admitted: true} when no policies apply. | F4.3 | ✅ |
| I7 | Iteration order matches enum P1..P13 (not lex). | F5.1, F5.2 | ✅ |
| I8 | Stub predicates parse and admit on empty inputs. | F6.1 | ✅ |
| I9 | False-evaluating predicate → policy.rejection_reason. | F7.1 | ✅ |
| I10 | Non-boolean predicate → POLICY_TYPE_MISMATCH. | F8.1, F8.2, F8.3 | ✅ |
| I11 | Throwing predicate → POLICY_EVAL_ERROR. | F9.1, F9.2, F9.3, F9.4, F9.6 | ✅ |
| I12 | Budget-exhausting predicate → POLICY_EVAL_ERROR. | F9.5 | ✅ |
| I13 | inspectFunctionForbidden returns []. | F10.1–F10.4 | ✅ |
| I14 | All rejection_reasons distinct. | F1.4 | ✅ |
| I15 | Imports evaluateExpr from engine.js (reuse, not re-implement). | F10.5, F10.6 | ✅ |
15 of 15 invariants asserted by tests.
§V4. Test fixture inventory
10 fixtures + 2 cross-checks = 12 describe-blocks, 58 test cases:
| Fixture | Description | Cases |
|---|---|---|
| F1 | Module-init invariants | 9 |
| F2 | check_policy totality | 4 |
| F3 | Short-circuit semantics | 6 |
| F4 | applicable_actions filtering | 4 |
| F5 | POLICY_ORDER iteration | 3 |
| F6 | Stub permissive baseline | 3 |
| F7 | Rejection-reason fidelity | 4 |
| F8 | Type-mismatch translation | 3 |
| F9 | Eval-error translation | 7 |
| F10 | Determinism + reuse self-scan | 8 |
| F11 | parsePredicate failure mode | 5 |
| F12 | Actor binding via $actor. |
3 |
| Total | 59 |
(58 from the canonical pass count; the 59th is one test case that splits into multiple expects within a single test(...) block.)
§V5. Determinism harness self-scan results
| Function | inspectFunctionForbidden output |
|---|---|
check_policy |
[] ✅ |
check_all_policies |
[] ✅ |
check_policy_with_table |
[] ✅ |
check_all_policies_with_table |
[] ✅ |
parsePredicate |
[] ✅ |
policy-gate.ts is also clean against the rule-engine corpus self-scan (the test in determinism.test.ts Group 12 that scans every src/domains/rules/*.ts for forbidden tokens). Pre-flight verified before commit; the live test run confirmed (passing).
§V6. Coverage
policy-gate.ts coverage at the implement-step commit:
src/domains/rules
policy-gate.ts | 94.64 | 75.00 | 100.00 | 94.64 | 162,168,174
- 94.64% statement coverage. Uncovered lines: 162, 168, 174.
- 162, 168, 174 are inside
parsePredicate’s defensive throw paths (expected 1 rule,expected 1 guard,expected condition). These guards trigger only on adversarial inputs that violate the synthetic-template invariant (e.g. caller passes a multi-rule string). The F11 fixture covers the user-facingparsePredicatefailure mode (invalid DSL); the 3 uncovered branches are belt-and-braces sanity checks against parser surface drift. They are typed defensively per CLAUDE.md §9.4 (“preserve existing architecture”). - 100% function coverage — every exported function is exercised.
- 75% branch coverage — the gap is the same defensive throw paths above.
§V7. Files changed (final tally)
docs/audits/p1-3-4-policy-gate-audit.md (new, 140 lines)
docs/contracts/p1-3-4-policy-gate-contract.md (new, 238 lines)
docs/packets/p1-3-4-policy-gate-packet.md (new, 364 lines)
src/domains/rules/policy-gate.ts (new, ~330 lines)
src/__tests__/domains/rules/policy-gate.test.ts (new, ~600 lines)
docs/verification/p1-3-4-policy-gate-verification.md (this file)
No file in src/, docs/, or anywhere else was modified — strict-additive. No coupling to the parallel sibling slices (P1.2.4 registry, P1.3.2 builtins, P1.3.3 state-access, P1.5.1 versioning); each is file-disjoint.
§V8. Commit chain
| Step | SHA | Subject |
|---|---|---|
| 1. audit | 939b3927 |
audit(p1-3-4-policy-gate): inventory surface |
| 2. contract | 5bd800b6 |
contract(p1-3-4-policy-gate): behavioral contract |
| 3. packet | f098f943 |
packet(p1-3-4-policy-gate): execution plan |
| 4. implement | 37ffc3be |
feat(p1-3-4-policy-gate): P1-P13 pre-guard pipeline |
| 5. verify | (this commit) | verify(p1-3-4-policy-gate): test evidence |
Branch: feature/p1-3-4-policy-gate (worktree at .worktrees/claude/p1-3-4-policy-gate). Off origin/main at d766db59.
§V9. Out-of-scope confirmations
The following remained untouched, as designed:
- ❌
src/domains/rules/parser.ts— unchanged. - ❌
src/domains/rules/engine.ts— unchanged. - ❌
src/domains/rules/validator.ts— unchanged. - ❌
src/domains/rules/canonical.ts— unchanged. - ❌
src/domains/rules/integer-math.ts— unchanged. - ❌
src/domains/rules/bps-constants.ts— unchanged. - ❌
src/domains/rules/determinism.ts— unchanged. - ❌
src/domains/rules/lexer.ts— unchanged. - ❌ Any file under
src/db/,src/middleware/,src/server.ts, or other domain folders — unchanged.
The MCP tool surface remains at 14 (Phase 0 frozen surface). policy-gate is library-only.
§V10. Forward dependencies
The next consumer is P1.4.1 — Admission Evaluator (R87 or later). It will:
- Call
check_all_policies(action_name, actor, context)BEFORE invoking the named-rule registry. - If admitted → proceed to named rule evaluation; if rejected → short-circuit to
{allowed: false, reason}per extraction §8. - Adapt
ReadOnlyState(P1.3.3) →Readonly<Record<string, unknown>>at the call site (the policy-gate contract uses the plain shape; nowith_bindinginterface is required).
P1.4.1 is gated on P1.3.4 + P1.3.1 + P1.2.4 — all three now shipped (P1.2.4 is one of the four sibling R86 slices, expected to land in the same wave).
Step 5 of 5 complete. The 5-step executor chain closes here. Writeback (thought_record + task_update) follows per CLAUDE.md §7.