P1.2.3 — κ AST Validator — Execution Packet

Step 3 of the 5-step executor chain. Builds on docs/audits/p1-2-3-validator-audit.md and docs/contracts/p1-2-3-validator-contract.md. Gates implementation (Step 4).

§P1. Files

Path Action Approx LOC
src/domains/rules/validator.ts CREATE ~430
src/__tests__/domains/rules/validator.test.ts CREATE ~500
docs/audits/p1-2-3-validator-audit.md created in Step 1
docs/contracts/p1-2-3-validator-contract.md created in Step 2
docs/packets/p1-2-3-validator-packet.md this file
docs/verification/p1-2-3-validator-verification.md created in Step 5

No other files touched. Worktree-scope discipline per audit §11.

§P2. Implementation outline — validator.ts

§P2.1. File header (JSDoc)

Multi-line module docstring covering:

  • Phase 1 κ Rule Engine — AST Validator (P1.2.3).
  • Pure module — no I/O, no DB, no env reads, no console; read-only over its input AST.
  • Builds on P1.2.2 parser; imports type-only AST node interfaces.
  • 7 independent checks composed without short-circuit.
  • Public surface lock (per contract §2): result types, constants, 7 check functions, 7 axiom sub-stubs, composer.
  • Reference list: audit, contract, rule-engine.md §Forbidden, constitution.md AX-01..07, kappa extraction §2 + §5.

§P2.2. Imports

import type {
  AnyNode,
  BinaryOp,
  BoolLiteral,
  EffectCall,
  Expression,
  FuncCall,
  GuardClause,
  IntLiteral,
  Location,
  LogicalOp,
  RuleNode,
  StringLiteral,
  UnaryOp,
  VarRef,
} from './parser.js';

type modifier on the import (TS import type) — eliminates runtime dependency on parser.ts for types only.

§P2.3. Public surface — types

export interface ValidationError {
  code: string;
  message: string;
  path: string[];
  location: Location | null;
}

export type ValidationResult =
  | { valid: true }
  | { valid: false; errors: ValidationError[] };

§P2.4. Public surface — constants

export const FORBIDDEN_FUNCTIONS: readonly string[] = Object.freeze([
  'time',
  'now',
  'read_file',
  'http_get',
  'random',
  'rand',
]);

export const IN_SCOPE_ROOTS: readonly string[] = Object.freeze([
  'event',
  'actor',
  'stake',
  'reputation',
  'token',
  'state',
  'obligation',
  'finality',
  'vrf_output',
]);

Object.freeze adds runtime immutability on top of readonly (compile-time). The Set lookups inside check functions use module-level const FORBIDDEN_SET = new Set(FORBIDDEN_FUNCTIONS) (same for IN_SCOPE_SET) — built once.

§P2.5. Internal helpers (NOT exported)

/**
 * Reason text per forbidden function name. Frozen.
 */
const FORBIDDEN_REASON: Readonly<Record<string, string>> = Object.freeze({
  time: 'clock reads are non-deterministic',
  now: 'clock reads are non-deterministic',
  read_file: 'filesystem reads are non-deterministic',
  http_get: 'network IO is non-deterministic and side-effecting',
  random: 'randomness is non-deterministic; use a precomputed VRF input',
  rand: 'randomness is non-deterministic; use a precomputed VRF input',
});

/** Append a path segment immutably. */
function pathExt(parent: readonly string[], segment: string): string[] {
  return [...parent, segment];
}

type Type = 'int' | 'bool' | 'string' | 'unknown';

§P2.6. forbiddenFunctions

export function forbiddenFunctions(rule: RuleNode): ValidationError[] {
  const errors: ValidationError[] = [];
  walkRule(rule, [], (node, path) => {
    let name: string | null = null;
    if (node.type === 'EffectCall') name = node.function;
    else if (node.type === 'FuncCall') name = node.name;
    if (name !== null && FORBIDDEN_SET.has(name)) {
      errors.push({
        code: 'FORBIDDEN_FUNCTION',
        message: `Forbidden function call: ${name}. Reason: ${FORBIDDEN_REASON[name]}.`,
        path,
        location: node.location,
      });
    }
  });
  return errors;
}

Where walkRule(rule, basePath, visitor) is the shared traversal helper (§P2.13).

§P2.7. sideEffectsInGuard

export function sideEffectsInGuard(rule: RuleNode): ValidationError[] {
  const errors: ValidationError[] = [];
  rule.guards.forEach((guard, gi) => {
    if (guard.condition === null) return;
    walkExpr(guard.condition, ['guards', String(gi), 'condition'], (node, path) => {
      if (node.type === 'FuncCall') {
        errors.push({
          code: 'SIDE_EFFECT_IN_GUARD',
          message: `Function call '${node.name}' not permitted in guard expression: guards must be read-only.`,
          path,
          location: node.location,
        });
      }
    });
  });
  return errors;
}

walkExpr(expr, basePath, visitor) recursively visits an Expression subtree (no rule-level traversal). Same as walkRule but starts from an Expression and skips guard/effect framing.

§P2.8. mutationOfInput

/**
 * Phase 1 — assignment is not in the κ DSL grammar; the parser cannot emit any
 * assignment-shaped AST node. This check therefore returns [] today. The
 * `INPUT_MUTATION` code is reserved for future grammar extensions.
 */
export function mutationOfInput(_rule: RuleNode): ValidationError[] {
  return [];
}

Plain stub. Function-arg name is _rule to satisfy no-unused-vars lint without disabling.

§P2.9. typeCompatibility

export function typeCompatibility(rule: RuleNode): ValidationError[] {
  const errors: ValidationError[] = [];
  // Walk every Expression position in guards and effect args.
  rule.guards.forEach((guard, gi) => {
    if (guard.condition !== null) {
      inferExpr(guard.condition, ['guards', String(gi), 'condition'], errors);
    }
  });
  rule.effects.forEach((effect, ei) => {
    effect.args.forEach((arg, ai) => {
      inferExpr(arg, ['effects', String(ei), 'args', String(ai)], errors);
    });
  });
  return errors;
}

/**
 * Infer the type of `expr` and push errors into `errors`. Returns the type of
 * the expression so parents can decide. Cascade-bounded: errors on a subtree
 * default the parent's incoming type to the expected result so a single
 * mismatch produces one error, not several.
 */
function inferExpr(expr: Expression, path: readonly string[], errors: ValidationError[]): Type {
  switch (expr.type) {
    case 'IntLiteral':    return 'int';
    case 'BoolLiteral':   return 'bool';
    case 'StringLiteral': return 'string';
    case 'VarRef':        return 'unknown';
    case 'FuncCall': {
      // Recurse into args for nested errors, but FuncCall return type is unknown.
      expr.args.forEach((a, i) => inferExpr(a, pathExt(path, 'args').concat(String(i)), errors));
      return 'unknown';
    }
    case 'UnaryOp': {
      const inner = inferExpr(expr.operand, pathExt(path, 'operand'), errors);
      if (inner === 'int' || inner === 'unknown') return 'int';
      errors.push({
        code: 'TYPE_INCOMPATIBLE',
        message: `Type mismatch: unary '-' requires int; got ${inner}.`,
        path: [...path],
        location: expr.location,
      });
      return 'int';
    }
    case 'LogicalOp': {
      const operandTypes = expr.operands.map((op, i) =>
        inferExpr(op, pathExt(path, 'operands').concat(String(i)), errors),
      );
      operandTypes.forEach((t, i) => {
        if (t !== 'bool' && t !== 'unknown') {
          errors.push({
            code: 'TYPE_INCOMPATIBLE',
            message: `Type mismatch: logical '${expr.op}' operand requires bool; got ${t}.`,
            path: pathExt(path, 'operands').concat(String(i)),
            location: expr.operands[i]!.location,
          });
        }
      });
      return 'bool';
    }
    case 'BinaryOp': {
      const lt = inferExpr(expr.left, pathExt(path, 'left'), errors);
      const rt = inferExpr(expr.right, pathExt(path, 'right'), errors);
      const op = expr.op;
      const isArith = op === '+' || op === '-' || op === '*' || op === '/' || op === '%';
      const isOrder = op === '<' || op === '>' || op === '<=' || op === '>=';
      const isEq    = op === '==' || op === '!=';
      if (isArith) {
        const lOK = lt === 'int' || lt === 'unknown';
        const rOK = rt === 'int' || rt === 'unknown';
        if (!lOK || !rOK) {
          errors.push({
            code: 'TYPE_INCOMPATIBLE',
            message: `Type mismatch: '${op}' requires int operands; got ${lt} and ${rt}.`,
            path: [...path],
            location: expr.location,
          });
        }
        return 'int';
      }
      if (isOrder) {
        const lOK = lt === 'int' || lt === 'unknown';
        const rOK = rt === 'int' || rt === 'unknown';
        if (!lOK || !rOK) {
          errors.push({
            code: 'TYPE_INCOMPATIBLE',
            message: `Type mismatch: '${op}' requires int operands; got ${lt} and ${rt}.`,
            path: [...path],
            location: expr.location,
          });
        }
        return 'bool';
      }
      // isEq
      if (lt !== 'unknown' && rt !== 'unknown' && lt !== rt) {
        errors.push({
          code: 'TYPE_INCOMPATIBLE',
          message: `Type mismatch: '${op}' requires same-type operands; got ${lt} and ${rt}.`,
          path: [...path],
          location: expr.location,
        });
      }
      return 'bool';
    }
  }
}

The walker is recursive (depth bounded by AST cap = 10000 from parser; safe).

§P2.10. scopeCheck

export function scopeCheck(rule: RuleNode): ValidationError[] {
  const errors: ValidationError[] = [];
  walkRule(rule, [], (node, path) => {
    if (node.type === 'VarRef') {
      const root = node.path[0];
      if (root === undefined || !IN_SCOPE_SET.has(root)) {
        const display = node.path.length === 0 ? '' : node.path.join('.');
        const rootDisplay = root ?? '(empty)';
        errors.push({
          code: 'UNDEFINED_VAR',
          message: `Variable '$${display}' is undefined: top-level root '${rootDisplay}' is not in κ context.`,
          path,
          location: node.location,
        });
      }
    }
  });
  return errors;
}

§P2.11. cycleDetection

/**
 * Phase 1 — single-rule scope. The parser produces a tree by construction;
 * within one rule there are no cycles. Cross-rule reference cycles are detected
 * by the registry (P1.2.4) at build time. The `CYCLE_DETECTED` code is reserved.
 */
export function cycleDetection(_rule: RuleNode): ValidationError[] {
  return [];
}

§P2.12. axiomCheck + 7 stubs

/** AX-01: Append-Only Events — events are never deleted; corrections are new events. */
export function checkAxiom01(_rule: RuleNode): ValidationError[] { return []; }

/** AX-02: Reputation is Derived — never assigned, only computed from history. */
export function checkAxiom02(_rule: RuleNode): ValidationError[] { return []; }

/** AX-03: No Absolute Authority — no role bypasses consequence chains. */
export function checkAxiom03(_rule: RuleNode): ValidationError[] { return []; }

/** AX-04: Consequence Windows — sanctioned subjects get an admission window. */
export function checkAxiom04(_rule: RuleNode): ValidationError[] { return []; }

/** AX-05: Subjective Finality — events are fact when valid + signed + accepted. */
export function checkAxiom05(_rule: RuleNode): ValidationError[] { return []; }

/** AX-06: Right to Exit — fork penalty must not exceed 10% reputation. */
export function checkAxiom06(_rule: RuleNode): ValidationError[] { return []; }

/** AX-07: Technical Sovereignty — each node validates independently. */
export function checkAxiom07(_rule: RuleNode): ValidationError[] { return []; }

export function axiomCheck(rule: RuleNode): ValidationError[] {
  return [
    ...checkAxiom01(rule),
    ...checkAxiom02(rule),
    ...checkAxiom03(rule),
    ...checkAxiom04(rule),
    ...checkAxiom05(rule),
    ...checkAxiom06(rule),
    ...checkAxiom07(rule),
  ];
}

§P2.13. Shared traversal helpers (NOT exported)

type Visitor = (node: AnyNode, path: readonly string[]) => void;

/** Walk every node of a RuleNode in pre-order; invoke `visit` on each. */
function walkRule(rule: RuleNode, basePath: readonly string[], visit: Visitor): void {
  visit(rule, basePath);
  rule.guards.forEach((guard, gi) => {
    const gpath = pathExt(basePath, 'guards').concat(String(gi));
    visit(guard, gpath);
    if (guard.condition !== null) {
      walkExpr(guard.condition, pathExt(gpath, 'condition'), visit);
    }
  });
  rule.effects.forEach((effect, ei) => {
    const epath = pathExt(basePath, 'effects').concat(String(ei));
    visit(effect, epath);
    effect.args.forEach((arg, ai) => {
      walkExpr(arg, pathExt(epath, 'args').concat(String(ai)), visit);
    });
  });
}

/** Walk every node of an Expression subtree in pre-order. */
function walkExpr(expr: Expression, basePath: readonly string[], visit: Visitor): void {
  visit(expr, basePath);
  switch (expr.type) {
    case 'BinaryOp':
      walkExpr(expr.left, pathExt(basePath, 'left'), visit);
      walkExpr(expr.right, pathExt(basePath, 'right'), visit);
      break;
    case 'UnaryOp':
      walkExpr(expr.operand, pathExt(basePath, 'operand'), visit);
      break;
    case 'LogicalOp':
      expr.operands.forEach((op, i) =>
        walkExpr(op, pathExt(basePath, 'operands').concat(String(i)), visit),
      );
      break;
    case 'FuncCall':
      expr.args.forEach((arg, i) =>
        walkExpr(arg, pathExt(basePath, 'args').concat(String(i)), visit),
      );
      break;
    // Leaves
    case 'IntLiteral':
    case 'BoolLiteral':
    case 'StringLiteral':
    case 'VarRef':
      break;
  }
}

§P2.14. Composer

export function validate(rule: RuleNode): ValidationResult {
  const errors: ValidationError[] = [
    ...forbiddenFunctions(rule),
    ...sideEffectsInGuard(rule),
    ...mutationOfInput(rule),
    ...typeCompatibility(rule),
    ...scopeCheck(rule),
    ...cycleDetection(rule),
    ...axiomCheck(rule),
  ];
  if (errors.length === 0) {
    return { valid: true };
  }
  return { valid: false, errors };
}

§P3. Implementation outline — validator.test.ts

§P3.1. Imports + helpers

import {
  validate,
  forbiddenFunctions,
  sideEffectsInGuard,
  mutationOfInput,
  typeCompatibility,
  scopeCheck,
  cycleDetection,
  axiomCheck,
  checkAxiom01,
  checkAxiom02,
  checkAxiom03,
  checkAxiom04,
  checkAxiom05,
  checkAxiom06,
  checkAxiom07,
  FORBIDDEN_FUNCTIONS,
  IN_SCOPE_ROOTS,
} from '../../../domains/rules/validator.js';
import type { ValidationError, ValidationResult } from '../../../domains/rules/validator.js';
import { parse } from '../../../domains/rules/parser.js';
import type { RuleNode } from '../../../domains/rules/parser.js';

/** Parse one rule, asserting clean parse, return the RuleNode. */
function parseSingleRule(src: string): RuleNode {
  const r = parse(src);
  expect(r.errors).toEqual([]);
  expect(r.ast).toHaveLength(1);
  return r.ast[0]!;
}

/** Wrap an expression as a guard condition. */
function wrapGuard(expr: string): string {
  return `rule R { guards { ${expr} -> admit } effects { } }`;
}

/** Wrap an effect call name + args into a rule body. */
function wrapEffect(call: string): string {
  return `rule R { guards { else -> admit } effects { ${call} } }`;
}

§P3.2. Test groups

F1 — forbiddenFunctions (5–6 tests)

  • now() in guard → 1 error code FORBIDDEN_FUNCTION, message contains 'now'.
  • time() in guard → 1 error.
  • read_file("/etc/passwd") as effect call → 1 error code FORBIDDEN_FUNCTION.
  • random() in expression → 1 error.
  • rand() in expression → 1 error.
  • http_get("url") as effect call → 1 error.
  • min(1, 2) (non-blocked) → no error from forbiddenFunctions (note: still flagged by sideEffectsInGuard if in guard; tested separately).

F1c — VarRef allowlist sanity

  • $vrf_output > 0 -> admit (no FuncCall — VarRef only) → no FORBIDDEN_FUNCTION error from forbiddenFunctions.

F2 — sideEffectsInGuard (3–4 tests)

  • Any FuncCall in guard (e.g. min($a, 1) > 0) → 1 error code SIDE_EFFECT_IN_GUARD.
  • Multiple FuncCalls → multiple errors.
  • No FuncCall in guard → no error.
  • FuncCall in effect args (not guard) → no error from this check.

F3 — mutationOfInput (1 test)

  • Any valid rule → returns []. Documents that grammar gates this.

F4 — typeCompatibility (8–10 tests)

  • 5 + "foo" → 1 error code TYPE_INCOMPATIBLE.
  • 1 == "1" → 1 error.
  • not 5 → 1 error.
  • true and 1 → 1 error.
  • 5 < "x" → 1 error.
  • $x + 5 → no error (unknown).
  • $a == "literal" → no error (unknown propagates).
  • 5 + 5 → no error.
  • true and false → no error.
  • not (1 == 2) → no error.

F5 — scopeCheck (4–5 tests)

  • $undefined > 0 in guard → 1 error code UNDEFINED_VAR.
  • $event.id == 0 in guard → no error (event is in-scope).
  • $actor.reputation > 100 → no error.
  • $event.id == 0 and $bogus.x == 0 → 1 error (only $bogus).
  • VarRef inside effect args → also checked.

F6 — cycleDetection (1 test)

  • Any rule → returns [].

F7 — axiomCheck stubs (8 tests)

  • Each of 7 checkAxiomNN returns [].
  • axiomCheck returns [].

F8 — happy path (3 tests)

  • AcceptCommitment-modeled valid rule → {valid: true}.
  • A rule with no guards (only else -> admit) and no effects → {valid: true}.
  • A rule with simple expression $event.id == 1 -> admit and a single effect → {valid: true}.

F8b — read-only (1 test)

  • Take a rule, JSON-serialize a deep copy, run validate, JSON-serialize the rule again, assert equality.
    • Use the parser-level jsonifyAst style (handle bigint via a JSON.stringify replacer).

F9 — multi-error aggregation (2 tests)

  • A rule with 3 distinct issues (e.g., now() call + 5 + "foo" + $undefined) → errors.length === 3, codes match {FORBIDDEN_FUNCTION, TYPE_INCOMPATIBLE, UNDEFINED_VAR} in some order.
  • A 4-issue rule → errors.length === 4.

F10 — constants exposed

  • FORBIDDEN_FUNCTIONS matches expected list.
  • IN_SCOPE_ROOTS matches expected list.
  • Both are frozen (mutating raises in strict mode; assert via Object.isFrozen).

F11 — ValidationError shape

  • For any non-empty error result, every error has: non-empty code, non-empty message, array path, location (Object or null).

§P3.3. Test count target

~35–45 cases across 11 fixture groups. Comparable to lexer (22 cases on a smaller surface) and parser (~50 cases on a wider surface).

§P4. Build / lint / test plan

Command Purpose Expected
npm run build TS compile to dist/ exit 0; no new TS errors
npm run lint ESLint on src/, src/__tests__/ exit 0; no new warnings
npm test Jest suite all green; baseline +X tests where X ≈ 35–45

Pre-existing baseline (per memory): 1467 tests at 7218b34b. After P1.2.3: 1502–1512 expected.

§P5. Commit plan

# Commit message Files
1 audit(p1-2-3-validator): inventory surface (already shipped at 444e8916) docs/audits/p1-2-3-validator-audit.md
2 contract(p1-2-3-validator): behavioral contract (already shipped at 86aeded2) docs/contracts/p1-2-3-validator-contract.md
3 packet(p1-2-3-validator): execution plan (this commit) docs/packets/p1-2-3-validator-packet.md
4 feat(p1-2-3-validator): 7-check ast walker src/domains/rules/validator.ts + src/__tests__/domains/rules/validator.test.ts
5 verify(p1-2-3-validator): test evidence docs/verification/p1-2-3-validator-verification.md

§P6. Risk + mitigation re-statement (from audit §11)

  • Confusing EffectCall vs FuncCall in walker → shared walkRule/walkExpr traversal; explicit type tags; tests cover both.
  • Type inference cascade explosion → bounded: each operator emits at most 1 error and falls through to its expected return type.
  • Walker mutating ASTpathExt is immutable; no [] written to nodes; F8b deep-equal pre/post.
  • Conservative sideEffectsInGuard → documented as intentional; loosening is post-ADR.
  • noUncheckedIndexedAccess → every arr[i] followed by an i < arr.length guard or !-cast post-bounds-check.

§P7. Push + PR plan

After Step 5 verification doc:

unset GITHUB_TOKEN
cd .worktrees/claude/p1-2-3-validator
git push -u origin feature/p1-2-3-validator
gh pr create --title "feat(p1-2-3-validator): 7-check AST walker (R85 κ Wave 4)" --body @<heredoc>

PR body covers: goal, the 7 checks, what’s active vs reserved, test count delta, links to audit/contract/packet/verification docs.

§P8. Writeback plan (CLAUDE.md §7)

mcp__colibri__thought_record({
  type: "reflection",
  task_id: "76f84da5-0dfc-43fe-8c8d-73135087d7e6",
  content: "task_id: P1.2.3
branch: feature/p1-2-3-validator
worktree: .worktrees/claude/p1-2-3-validator
commit: <SHA>
pr: <URL>
tests: npm run build && npm run lint && npm test → all green; +N tests
summary: 7-check AST walker over P1.2.2 RuleNode. Active checks: forbiddenFunctions, sideEffectsInGuard, typeCompatibility, scopeCheck. Stub checks: mutationOfInput (grammar-gated), cycleDetection (registry concern), axiomCheck (7 named stubs for π wiring). Aggregate, no short-circuit. Read-only over input AST.
blockers: none"
})

mcp__colibri__task_update({
  id: "76f84da5-0dfc-43fe-8c8d-73135087d7e6",
  patch: { status: "DONE" }
})

§P9. Gate

Step 4 implementation MAY proceed once this packet is committed. The contract (Step 2) and packet (Step 3) together fully specify the source. The audit (Step 1) inventoried the surface. Steps 1–3 are now committed; implementation is unblocked.

Next step: implement (Step 4 of 5).


Back to top

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

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