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 forsrc/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.js—parse, types:Expression,ParseError../engine.js—evaluateExpr, 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:
- Look up
POLICIES[id]. (TypeScript guarantees existence — enum value → Record key.) - 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=0nbudget= fresh{integer_ops: 0, call_depth: 0, current_arg_count: 0}(per-policy reset)
- Call
evaluateExpr(policy.predicate_ast, ctx). - 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'}.
- Returned value
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:
- Iterate POLICIES in ascending PolicyId order (
P1,P2, …,P13). The order is fixed at module init via a frozenreadonly PolicyId[]. Not alphabetical-string sort (P1<P10lexicographically would putP10,P11,P12,P13betweenP1andP2— wrong). Use the enum’s declaration order. - For each policy: skip unless
applicable_actionsincludes the literal'*'ORaction_name. - For applicable policies, call
check_policy(id, actor, context). - Short-circuit: on the first
{admitted: false}result, return it immediately. The remaining policies are not evaluated. (Test fixture F3 asserts this directly.) - If all applicable policies admit, return
{admitted: true}. - 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’sresolveVarRefhandles dotted paths via theeventandstatechains, not via bindings (bindings are flat single-segment lookups). - We therefore inject the actor into the engine
eventfield (not into bindings). This makes$actor.<anything>a valid VarRef path that descendsevent['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= emptyMap.- 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_reasonis 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 assertObject.isFrozen(POLICIES). - ❌ No re-implementation of expression evaluation. Reuse
evaluateExprend-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 includeerr.messagein 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 modulesengine.tsuse both —evaluate,evaluateExpr,executeRulesetare camelCase, but extraction-named functions likeinspectFunctionForbiddenkeep 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 testall 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_TOKENbefore 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.