P1.3.4 — κ Policy Gating / Pre-guards — Behavioral Contract

Step 2 of the 5-step executor chain. Builds on docs/audits/p1-3-4-policy-gate-audit.md. Defines the public surface, semantics, and invariants for src/domains/rules/policy-gate.ts.

§1. Module identity

  • Path: src/domains/rules/policy-gate.ts
  • Axis: κ — Rule Engine (Phase 1 Wave 5)
  • Kind: pure synchronous module; no I/O, no DB access, no network, no env reads, no console output, no clock, no RNG.
  • Internal dependencies:
    • ./parser.jsparse, types: Expression, ParseError.
    • ./engine.jsevaluateExpr, types: Context, BudgetTracker. Class: RuleBudgetExceeded (caught and translated; not re-exported).
  • No imports from src/db/*, src/middleware/*, src/server.ts, other domain folders, Node built-ins, or any state-access surface.

§2. Public API

§2.1. PolicyId enum

export enum PolicyId {
  P1 = 'P1',
  P2 = 'P2',
  // ...
  P13 = 'P13',
}

String-valued enum (not numeric). The string values match the extraction §9 names verbatim. Stable: any reorder is a breaking change.

§2.2. Policy interface

export interface Policy {
  readonly id: PolicyId;
  readonly predicate_source: string;     // raw DSL source — preserved for debug
  readonly predicate_ast: Expression;    // parsed once at module init
  readonly rejection_reason: string;     // pre-registered, static
  readonly applicable_actions: readonly string[];
  // ['*'] = applies to every action; [] = disabled
}

Construction: every field is set once at module init via the parser. Subsequent reads are read-only via as const typing — TypeScript enforces immutability at the type level even if a runtime Object.freeze is omitted. The implementation freezes the array literal as an extra defensive measure.

§2.3. PolicyResult discriminated union

export type PolicyResult =
  | { readonly admitted: true }
  | { readonly admitted: false; readonly reason: string };

The admitted variant carries no payload — there is nothing to surface beyond “policy passed”. The rejected variant always carries a reason string. The discriminant is the literal admitted boolean.

§2.4. POLICIES — module-init table

export const POLICIES: Readonly<Record<PolicyId, Policy>>;

A frozen Record<PolicyId, Policy> populated at module load. Every PolicyId in the enum must have an entry. The table is exported so tests can inspect it (assert all 13 entries present, every predicate parses, every reason is unique). Per task prompt: stub predicates default-admit (predicate source = "true"), so admission flow upstream remains permissive until later rounds substitute real predicates.

§2.5. check_policy

export function check_policy(
  id: PolicyId,
  actor: Readonly<Record<string, unknown>>,
  context: Readonly<Record<string, unknown>>,
): PolicyResult;

Semantics:

  1. Look up POLICIES[id]. (TypeScript guarantees existence — enum value → Record key.)
  2. Construct an engine.Context:
    • event = {} (frozen empty)
    • state = context (passed through, treated read-only)
    • bindings = new Map([['actor', /* see §2.7 */]])
    • rule_version = 'policy-gate' (sentinel; predicates may reference but not branch on)
    • epoch = 0n
    • budget = fresh {integer_ops: 0, call_depth: 0, current_arg_count: 0} (per-policy reset)
  3. Call evaluateExpr(policy.predicate_ast, ctx).
  4. Result mapping:
    • Returned value === true{admitted: true}.
    • Returned value === false{admitted: false, reason: policy.rejection_reason}.
    • Returned value is non-boolean (bigint / string) → {admitted: false, reason: 'POLICY_TYPE_MISMATCH'}.
    • Any thrown error (RuleBudgetExceeded, OverflowError, DivisionByZeroError, undefined_variable, undefined_function, type_mismatch) → {admitted: false, reason: 'POLICY_EVAL_ERROR'}.

Determinism: synchronous; never throws; same (id, actor, context) → same result.

§2.6. check_all_policies

export function check_all_policies(
  action_name: string,
  actor: Readonly<Record<string, unknown>>,
  context: Readonly<Record<string, unknown>>,
): PolicyResult;

Semantics:

  1. Iterate POLICIES in ascending PolicyId order (P1, P2, …, P13). The order is fixed at module init via a frozen readonly PolicyId[]. Not alphabetical-string sort (P1 < P10 lexicographically would put P10, P11, P12, P13 between P1 and P2 — wrong). Use the enum’s declaration order.
  2. For each policy: skip unless applicable_actions includes the literal '*' OR action_name.
  3. For applicable policies, call check_policy(id, actor, context).
  4. Short-circuit: on the first {admitted: false} result, return it immediately. The remaining policies are not evaluated. (Test fixture F3 asserts this directly.)
  5. If all applicable policies admit, return {admitted: true}.
  6. If no policy is applicable to action_name, return {admitted: true} (the empty product). This matches the extraction’s pseudocode: for policy_id in applicable: ...; return PolicyResult { admitted: true }.

Determinism: synchronous; never throws; same (action_name, actor, context) → same result.

§2.7. Actor binding encoding

engine.Context.bindings has type ReadonlyMap<string, bigint | string | boolean>. The actor is delivered as Readonly<Record<string, unknown>>. To bridge:

  • A predicate references $actor.reputation.execution — the engine’s resolveVarRef handles dotted paths via the event and state chains, not via bindings (bindings are flat single-segment lookups).
  • We therefore inject the actor into the engine event field (not into bindings). This makes $actor.<anything> a valid VarRef path that descends event['actor'].
  • Bindings stays empty for now — there is no scalar that needs single-key access in the stub predicates. (Future rounds may add scalar bindings; the contract leaves room.)

Refined §2.5 step 2:

  • event = { actor } — actor injected as a top-level key.
  • state = context.
  • bindings = empty Map.
  • Other fields as listed.

This way, $actor.x.y resolves via event.actor.x.y — exactly the shape the extraction prescribes.

§3. Invariants

ID Invariant Test class
I1 All 13 PolicyIds have an entry in POLICIES at module load. F1
I2 Every Policy.predicate_ast is a valid Expression (parses without error). F1
I3 Every Policy.rejection_reason is non-empty. F1
I4 check_policy is total — never throws, always returns a PolicyResult. F2
I5 check_all_policies short-circuits — given a chain [P_a, P_b, P_c] where P_b rejects, P_c.predicate_ast is never evaluated. F3
I6 check_all_policies returns {admitted: true} when no policies apply to the given action. F4
I7 Iteration order over POLICIES is deterministic and matches enum declaration order P1..P13. F5
I8 Stub predicates parse and admit under empty actor + empty context. F6
I9 A predicate evaluating to false rejects with the policy’s pre-registered rejection_reason. F7
I10 A predicate evaluating to a non-boolean (bigint, string) rejects with POLICY_TYPE_MISMATCH. F8
I11 A predicate that throws (any kind) rejects with POLICY_EVAL_ERROR. F9
I12 A predicate that exhausts the per-policy MAX_INTEGER_OPS budget rejects with POLICY_EVAL_ERROR. F9
I13 Determinism self-scan: inspectFunctionForbidden(check_policy) and inspectFunctionForbidden(check_all_policies) both return []. F10
I14 Each policy’s rejection_reason is distinct across the table (no collisions). F1
I15 Reusing the engine: the implementation imports evaluateExpr from ./engine.js (not a local re-implementation). Test asserts via static-string scan of policy-gate.ts. F10

§4. Forbidden behaviours

  • No dynamic rejection strings. policy.rejection_reason is a literal string, fixed at module init. Format strings like \policy ${id} failed for ${actor}`` are forbidden — they leak runtime state into the determinism boundary and complicate test assertions.
  • No mutation of POLICIES. The table is Readonly<Record<...>>. Tests assert Object.isFrozen(POLICIES).
  • No re-implementation of expression evaluation. Reuse evaluateExpr end-to-end. Custom shortcuts (e.g. “if predicate text === ‘true’ just return true”) are forbidden — they break the invariant that policy evaluation goes through the same code path as named-rule evaluation.
  • No async, no await, no Promise. check_policy / check_all_policies are synchronous functions returning a synchronous PolicyResult. Callers (P1.4.1 admission evaluator) compose this synchronously.
  • No Math., Date., crypto.*, fetch, setTimeout/setInterval/setImmediate, process.hrtime/nextTick, float literals. Determinism harness self-scan asserts.
  • No leaks of engine-internal error messages. Catching evaluateExpr’s thrown errors must produce the opaque 'POLICY_EVAL_ERROR' reason. The catch must not include err.message in the reason. (This is a deliberate boundary — engine errors carry path / variable names which are debug-grade, not policy-grade.)

§5. Naming conventions

  • Function names: check_policy, check_all_policies — snake_case to match the extraction §9 pseudocode verbatim. (Surrounding TypeScript is camelCase; the snake_case here is a deliberate spec-tracking convention. Sibling pure modules engine.ts use both — evaluate, evaluateExpr, executeRuleset are camelCase, but extraction-named functions like inspectFunctionForbidden keep their canonical names.)
  • Constant names: SCREAMING_SNAKE_CASE for module-level constants (POLICIES, MAX_INTEGER_OPS).
  • Type names: PascalCase (PolicyId, Policy, PolicyResult).

§6. Module-init policy seed

The 13 stub policies share a uniform shape:

id predicate_source rejection_reason applicable_actions
P1 "true" "P1_NOT_AUTHORIZED" ["*"]
P2 "true" "P2_INSUFFICIENT_REPUTATION" ["*"]
P3 "true" "P3_INSUFFICIENT_TOKENS" ["*"]
P4 "true" "P4_INSUFFICIENT_STAKE" ["*"]
P5 "true" "P5_FORK_MISMATCH" ["*"]
P6 "true" "P6_RULE_VERSION_MISMATCH" ["*"]
P7 "true" "P7_EPOCH_BOUNDARY" ["*"]
P8 "true" "P8_RATE_LIMITED" ["*"]
P9 "true" "P9_QUARANTINED" ["*"]
P10 "true" "P10_SCHISM_PARTICIPATION_BLOCKED" ["*"]
P11 "true" "P11_INTEGRITY_DAMPENED" ["*"]
P12 "true" "P12_CONSENSUS_PENDING" ["*"]
P13 "true" "P13_PROOF_VERIFICATION_FAILED" ["*"]

The reason strings are descriptive sentinels. They are not user-facing (P1.4.1 may format them); they are invariant identifiers. Distinct prefixes (P1_…P13_) ensure the I14 distinctness check is structural, not text-search-prone.

§7. Error-translation table

Source Translation
Predicate returned true {admitted: true}
Predicate returned false {admitted: false, reason: policy.rejection_reason}
Predicate returned bigint / string {admitted: false, reason: 'POLICY_TYPE_MISMATCH'}
RuleBudgetExceeded thrown {admitted: false, reason: 'POLICY_EVAL_ERROR'}
OverflowError thrown {admitted: false, reason: 'POLICY_EVAL_ERROR'}
DivisionByZeroError thrown {admitted: false, reason: 'POLICY_EVAL_ERROR'}
Plain Error (undefined_variable, undefined_function, type_mismatch) {admitted: false, reason: 'POLICY_EVAL_ERROR'}
Non-Error throw (string, number, undefined) {admitted: false, reason: 'POLICY_EVAL_ERROR'}

The opaque error reason is deliberate — see §4 (“No leaks of engine-internal error messages”). Diagnostic forwarding is a P1.4.1 admission-evaluator concern; policy-gate’s surface is intentionally lean.

§8. Module-init failure mode

If any predicate fails to parse at module load (which would be a coding error, not user input — the seed predicates are hard-coded "true" strings), the module-init throws a synchronous Error from the top-level statement. Importing policy-gate.ts then propagates the throw — there is no graceful fallback, by design. The test suite asserts the seed table parses cleanly (I1–I3).

This avoids constructing a Policy whose predicate_ast field is null or undefined, which would force every consumer to null-check.

§9. Sibling slice coordination (R86 Wave 5)

policy-gate.ts is one of five parallel slices in R86 κ Wave 5:

Slice Path Coupling to policy-gate
P1.2.4 registry src/domains/rules/registry.ts None — registry stores named rules; policies are a separate table.
P1.3.2 builtins src/domains/rules/builtins.ts None — builtins land inside evaluateExpr (FuncCall dispatch). policy-gate stub predicates use no FuncCalls.
P1.3.3 state-access src/domains/rules/state-access.ts Decoupled by contract. policy-gate uses plain Readonly<Record<string, unknown>> for context, not the parallel-shipping ReadOnlyState interface. The follow-up (P1.4.1) will adapt ReadOnlyState → the plain shape at the call site.
P1.5.1 versioning src/domains/rules/versioning.ts None — versioning is a serialization concern; policy-gate references rule_version: 'policy-gate' as a sentinel string, never branches on it.

If state-access.ts merges to main before policy-gate, no rebase pain is expected — there’s no shared symbol. If it merges after, same. The five slices are file-disjoint by design.

§10. Constraints from CLAUDE.md

  • §3: edit only inside .worktrees/claude/p1-3-4-policy-gate. ✓
  • §5: npm run build && npm run lint && npm test all green. (Verified at Step 5.)
  • §6: 5-step chain; no step skipped.
  • §7: writeback (thought_record + task_update) at the end of Step 5.
  • §13: unset GITHUB_TOKEN before push.

Step 2 of 5 complete. Step 3 (packet) sequences the implementation; Step 4 (implement) writes policy-gate.ts and policy-gate.test.ts; Step 5 (verify) records test evidence.


Back to top

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

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