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.ts failed once during the full-suite run (the pre-existing startup — subprocess smoke pattern 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-facing parsePredicate failure 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:

  1. Call check_all_policies(action_name, actor, context) BEFORE invoking the named-rule registry.
  2. If admitted → proceed to named rule evaluation; if rejected → short-circuit to {allowed: false, reason} per extraction §8.
  3. Adapt ReadOnlyState (P1.3.3) → Readonly<Record<string, unknown>> at the call site (the policy-gate contract uses the plain shape; no with_binding interface 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.


Back to top

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

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