P1.2.3 — κ AST Validator — Execution Packet
Step 3 of the 5-step executor chain. Builds on
docs/audits/p1-2-3-validator-audit.mdanddocs/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 codeFORBIDDEN_FUNCTION, message contains'now'.time()in guard → 1 error.read_file("/etc/passwd")as effect call → 1 error codeFORBIDDEN_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) → noFORBIDDEN_FUNCTIONerror fromforbiddenFunctions.
F2 — sideEffectsInGuard (3–4 tests)
- Any FuncCall in guard (e.g.
min($a, 1) > 0) → 1 error codeSIDE_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 codeTYPE_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 > 0in guard → 1 error codeUNDEFINED_VAR.$event.id == 0in guard → no error (eventis 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
checkAxiomNNreturns[]. axiomCheckreturns[].
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 -> admitand 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
jsonifyAststyle (handle bigint via aJSON.stringifyreplacer).
- Use the parser-level
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_FUNCTIONSmatches expected list.IN_SCOPE_ROOTSmatches 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-emptymessage, arraypath,location(Object ornull).
§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
EffectCallvsFuncCallin walker → sharedwalkRule/walkExprtraversal; 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 AST →
pathExtis immutable; no[]written to nodes; F8b deep-equal pre/post. - Conservative
sideEffectsInGuard→ documented as intentional; loosening is post-ADR. noUncheckedIndexedAccess→ everyarr[i]followed by ani < arr.lengthguard 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).