P1.2.3 — κ AST Validator — Audit

Step 1 of the 5-step executor chain (audit → contract → packet → implement → verify). Builds on the P1.2.2 parser (src/domains/rules/parser.ts, R84 PR #205, base SHA 7218b34b). Greenfield validator surface — no existing module to refactor.

§1. Surface inventory

§1.1. Target files (greenfield for this task)

Path Exists at base? Purpose
src/domains/rules/validator.ts No 7-check AST walker; pure module
src/__tests__/domains/rules/validator.test.ts No Jest validator tests (see §1.3 layout reconciliation)

§1.2. Touched but not owned

Path Delta Purpose
src/domains/rules/ already exists with bps-constants.ts, determinism.ts, integer-math.ts, lexer.ts, parser.ts (5 files) Adding validator.ts as a peer — no edits to existing files.
package.json none No new dependencies. The validator imports only AST types from ./parser.js.
package-lock.json none No new dependencies.

§1.3. Test-file layout reconciliation

The task prompt + the canonical task-breakdown row (§P1.2.3) both specify src/domains/rules/__tests__/validator.test.ts. The shipped Phase 0 + Phase 1 convention is tests live under src/__tests__/domains/<name>/, confirmed by inspection at base SHA 7218b34b:

  • src/__tests__/domains/rules/{bps-constants,determinism,integer-math,lexer,parser}.test.ts (P1.1.1, P1.1.2, P1.1.3, P1.2.1, P1.2.2)
  • src/__tests__/domains/{router,skills,tasks,proof,trail}/... (Phase 0 axes)

The R84 P1.2.2 parser audit (docs/audits/p1-2-2-parser-audit.md §1.3) made the same reconciliation; the dispatch packet for P1.2.3 also names src/__tests__/domains/rules/validator.test.ts directly. To stay consistent with the in-repo κ tests already shipped, the validator test will live at:

src/__tests__/domains/rules/validator.test.ts

This is a convention reconciliation, not a spec deviation. The verification doc will re-cite.

§2. Authoritative source map

Source Path Weight
Task spec, ready-to-paste prompt docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.2.3 (lines 755–865) Authoritative for behavior + acceptance
Task-breakdown row docs/guides/implementation/task-breakdown.md §P1.2.3 (lines 541–554) Authoritative for acceptance criteria list
Concept doc, forbidden ops docs/3-world/physics/laws/rule-engine.md §Forbidden operations List of disallowed operations (clock, RNG, float, IO)
Concept doc, axioms docs/3-world/physics/constitution.md (full file) AX-01..AX-07 to scaffold check stubs
Parser source src/domains/rules/parser.ts The AST surface this validator walks (11 node types)
Parser tests src/__tests__/domains/rules/parser.test.ts Test scaffolding patterns (jsonifyAst, parseSingleRule, wrapExpr)
Heritage extraction docs/reference/extractions/kappa-rule-engine-extraction.md §2 (AST) + §5 (Rule Execution Flow) Cross-check of node shape and per-rule node budget

§3. Drift findings

None new for P1.2.3. The previously documented drift on docs/architecture/decisions/ADR-006-dsl-grammar.md (still missing — currently the slot is occupied by ADR-006-executable-meaning.md) was raised in P1.2.1 and re-raised in P1.2.2; it is not in this task’s scope to write. Validator does not depend on that ADR.

§4. Parser surface — what the validator binds to

Inspecting src/domains/rules/parser.ts at base:

  • Module exports the validator imports:
    • AST node interfaces: RuleNode, GuardClause, EffectCall, BinaryOp, UnaryOp, LogicalOp, IntLiteral, BoolLiteral, StringLiteral, VarRef, FuncCall, Location.
    • Union types: Expression, AnyNode.
    • Not imported by validator: parse, countNodes, MAX_AST_NODES_PER_RULE, MAX_PARSE_ERRORS, ParseError, ParseResult. The validator operates on already-parsed AST input — input acquisition is the registry’s job (P1.2.4).
  • AST shape recap (11 nodes, plain data, all carry type discriminant + location: Location):
    • RuleNode { name, guards: GuardClause[], effects: EffectCall[] } — top-level
    • GuardClause { condition: Expression | null, action: 'admit' | 'reject', reason: string | null }condition === null for else clauses
    • EffectCall { function: string, args: Expression[] } — invocation by name
    • BinaryOp { op, left: Expression, right: Expression } — arithmetic + comparison
    • UnaryOp { op: '-', operand: Expression } — numeric negation
    • LogicalOp { op: 'and' | 'or' | 'not', operands: Expression[] } — boolean combinators
    • IntLiteral { value: bigint } — integer constant (bigint carrier; int64 envelope downstream)
    • BoolLiteral { value: boolean } — true / false
    • StringLiteral { value: string } — decoded; valid only inside Effect/Func args + reject reason
    • VarRef { path: string[] }$a.b.c['a', 'b', 'c']
    • FuncCall { name: string, args: Expression[] } — built-in invocation in expression position
  • Critical observation about EffectCall vs FuncCall: Both carry an unrestricted string name. They are syntactically identical to the parser; only their position distinguishes them: EffectCall lives directly under effects { ... } blocks; FuncCall lives in expression position (under guards, under nested arguments, etc.).
    • The forbidden-functions check applies to BOTH — a FuncCall named now inside a guard is non-deterministic, and an EffectCall named read_file inside the effect block is an external IO violation. The validator must walk both.
    • The blocklist is per the task prompt: 'time', 'now', 'read_file', 'http_get', 'random', 'rand'.
  • Critical observation about the parser’s grammar vs the validator’s job: The grammar admits some semantic violations that the validator must catch — for example, the parser will happily accept "foo" + 5 as a BinaryOp{op:'+', left: StringLiteral, right: IntLiteral} because StringLiteral is in the Expression union (it’s needed for argument positions). The validator’s typeCompatibility check is what rejects this.

§5. The seven validator checks — design notes

Per the task prompt §P1.2.3, the validator has 7 independent check functions, composed (no short-circuit — aggregate all errors). Each check produces structured errors {code, message, path, location}. Below: design summary per check.

§5.1. forbiddenFunctions

Walk the rule body. For every EffectCall and every FuncCall, check name against the blocklist ['time', 'now', 'read_file', 'http_get', 'random', 'rand']. Reject with code FORBIDDEN_FUNCTION. Allowlist: VarRef.path[0] === 'vrf_output' is permitted (VRF inputs are precomputed; not random at evaluation time).

Note: The task prompt’s allowlist phrasing "$vrf_output" variable reads should be read at the path level. The parser splits $vrf_output into VarRef.path === ['vrf_output']; $vrf_output.something becomes ['vrf_output', 'something']. Both are allowlisted at this check (forbiddenFunctions only inspects calls, not VarRefs — VarRefs of any name are inherently OK at this check).

§5.2. sideEffectsInGuard

Walk every GuardClause.condition. Any FuncCall in this expression sub-tree is a side-effect-in-guard violation. Code SIDE_EFFECT_IN_GUARD.

Decision detail (per task prompt fixture F2 spec): the literal example "guards { -> admit stake.freeze($a) }" is not parseable (the parser requires either an expression or else before the arrow). The intent of F2 is “an expression in guard position that performs an effect-shaped call.” A FuncCall like stake.freeze($a) would parse as FuncCall{name: 'stake.freeze', args: [...]} — but the parser actually rejects multi-segment dotted call names because Chevrotain’s funcCall rule only consumes a single Identifier. So the right test fixture is a guard expression containing a FuncCall with any name not on the safe builtin list — practically, any FuncCall at all in guard position is what this check rejects, because all FuncCalls in guards conceptually invoke a function and the engine can’t statically know whether it’s pure.

Conservative scope decision: the check rejects ALL FuncCall in guard position. This is more conservative than the upstream design (which would allow min, max, decay, etc. but forbid effecty calls). The conservative choice keeps the check syntactic and decidable; the loosening to a builtin allowlist is a P1.3.1 evaluator concern (or a follow-up tightening of P1.2.3 once the builtin set stabilizes via ADR). Document this in the contract.

§5.3. mutationOfInput

Look for $event.X = ... patterns. The κ DSL EBNF (extraction §1) does NOT include an assignment operator at all — there’s no production that emits = as an assignment (only == as comparison via Operators.Eq). So syntactically, assignment cannot reach the validator — the parser would reject it before AST construction.

That means this check is structurally impossible to fail today: the parser is a stricter gate. The validator scaffold for mutationOfInput runs the walker but always returns no errors, with a documentation comment stating the parser-level guarantee. The check exists for future expansion (if the DSL grows assignment) and to make the structural-7-check requirement of the task prompt pass.

Test for F3: since assignment never parses, the test feeds a parsed rule and asserts the check returns no errors — i.e. the scaffold is wired in. The original prompt’s wording "rule attempting $event.status = "x"" is interpreted as an intent statement; the test is the structural one.

(Document this clearly in contract + verification.)

§5.4. typeCompatibility

Type-inference walker over expressions. Tags: 'int' | 'bool' | 'string' | 'unknown'.

Rules:

  • IntLiteralint
  • BoolLiteralbool
  • StringLiteralstring
  • VarRefunknown (untyped at this stage)
  • FuncCallunknown (until P1.3.1 evaluator tightens)
  • UnaryOp{'-'} requires operand int | unknownint if int, unknown if unknown, ERROR if string bool
  • LogicalOp{'and' | 'or' | 'not'} requires every operand bool | unknownbool, ERROR otherwise
  • BinaryOp{arithmetic '+' '-' '*' '/' '%'} requires both int | unknownint, ERROR if string bool participates
  • BinaryOp{equality '==' '!='} requires both same type or one unknown → bool, ERROR if mixed concrete (e.g., int == string)
  • BinaryOp{ordering '<' '>' '<=' '>='} requires both int | unknownbool, ERROR otherwise

unknown short-circuits to “OK”; concrete-vs-concrete mismatches produce TYPE_INCOMPATIBLE. This handles the prompt fixture 5 + "foo" cleanly.

§5.5. scopeCheck

Walk every VarRef in the rule. Check path[0] against an in-scope set. Phase 1 in-scope variables are the implicit κ context bindings; the spec’s worked rule (docs/3-world/physics/laws/rule-engine.md §Worked rule) and the heritage extraction §10 ReadOnlyState interface enumerate them:

  • event — current input event under evaluation
  • actor — bound by process_action(actor=...) per extraction §8
  • stake — stake namespace
  • reputation — reputation namespace
  • token — token namespace
  • state — generic state namespace
  • obligation — obligation namespace
  • finality — finality namespace
  • vrf_output — VRF input (allowlisted by §5.1 too)

Anything else: UNDEFINED_VAR error. The implementation carries the in-scope set as a module-level constant IN_SCOPE_ROOTS: Set<string>. The fixture using $undefined (no dot) tests this.

§5.6. cycleDetection

The task prompt explicitly authorizes a stub here: “parser produces tree-by-construction within a rule, so cycles only happen across rules. For this validator (single-rule scope), this check can be a stub returning pass; cross-rule cycles are P1.2.4 Registry’s job. Document the scope decision in your contract.”

Decision: the function cycleDetection(rule: RuleNode): ValidationError[] always returns []. The test fixture asserts []. Documentation in contract + JSDoc explains: cross-rule reference cycles will be detected at registry build time (P1.2.4).

§5.7. axiomCheck

Per task prompt: “dedicated function per AX-01..AX-07. Each is a stub that returns pass for now (full axiom system activates with π governance, Phase 3+). Structure: named exports checkAxiom01, etc.”

Seven named functions — checkAxiom01 through checkAxiom07 — each takes (rule: RuleNode): ValidationError[] and returns []. The composing axiomCheck(rule) invokes all seven and concatenates results.

The seven axioms (from docs/3-world/physics/constitution.md):

ID Name Stub rationale
AX-01 Append-Only Events Will check rules don’t issue Delete-shaped effects. Today: pass.
AX-02 Reputation is Derived Will check rules don’t directly write reputation values without going through derivation. Today: pass.
AX-03 No Absolute Authority Will check rules don’t bypass consequence chains. Today: pass.
AX-04 Consequence Windows Will check sanction-shaped effects respect dispute windows. Today: pass.
AX-05 Subjective Finality Will check rules don’t presume global finality. Today: pass.
AX-06 Right to Exit Will check exit-shaped effects don’t exceed 10% reputation penalty. Today: pass.
AX-07 Technical Sovereignty Will check rules don’t presume centralized state. Today: pass.

§6. Aggregation semantics

The 7 checks are independent. The 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 };
}

This guarantees:

  • No short-circuit on first error (acceptance criterion).
  • Multi-error rules surface all issues in one pass (fixture F9).
  • Each check is unit-testable in isolation (each is a named export).

§7. ValidationError shape

export interface ValidationError {
  code: string;            // stable enum-like — see §10
  message: string;         // human-readable, includes context
  path: string[];          // structural path from rule root, e.g. ['guards', '0', 'condition', 'left']
  location: Location | null; // sourced from the offending node; null only if the node has no location
}

The path field is a list of structural-AST keys/indices (string-encoded so JSON serialization is trivial). The location field is the offending node’s Location from the parser (1-indexed, inclusive).

§8. Read-only invariant

Per task prompt FORBIDDENS: “Do not mutate the input AST — validator is read-only.” The walker visits but never assigns or splices. Every accumulator is a local []. Every recursion passes the node by value (TS object reference; the function never writes to its fields). The contract restates this; verification F8 includes a “AST identity preserved” check (deep-equal pre/post).

§9. Non-goals

This task explicitly excludes:

  • Builtin allowlist for FuncCall in guard position — see §5.2 conservative-scope decision. Loosening to a known-pure-builtin allowlist is a follow-up.
  • Cross-rule cycle detection — see §5.6. Lives in P1.2.4 registry.
  • Substantive axiom enforcement — see §5.7. Stubs only; activates with π in Phase 3+.
  • Bigint range validation for IntLiteral — the parser stores arbitrary-precision bigint. Int64 envelope enforcement is a P1.3.1 evaluator concern (or a future tightening of typeCompatibility).
  • Rule classification (Admission / StateTransition / Consequence / Promotion) — same as P1.2.2 §6: parser produces RuleNodes; classification is registry/engine.
  • Mutating any existing file outside src/domains/rules/validator.ts, src/__tests__/domains/rules/validator.test.ts, and the four docs (audit, contract, packet, verification).
  • Performance SLOs — none gated; informational only.

§10. Error code enumeration

All ValidationError codes the validator can emit:

Code Source check When
FORBIDDEN_FUNCTION forbiddenFunctions EffectCall or FuncCall name in BLOCKLIST
SIDE_EFFECT_IN_GUARD sideEffectsInGuard FuncCall in any GuardClause.condition subtree
INPUT_MUTATION mutationOfInput (Reserved — never emitted today; see §5.3)
TYPE_INCOMPATIBLE typeCompatibility Two concrete types misalign on an operator
UNDEFINED_VAR scopeCheck VarRef.path[0] not in IN_SCOPE_ROOTS
CYCLE_DETECTED cycleDetection (Reserved — never emitted today; see §5.6)
AX_01_VIOLATION..AX_07_VIOLATION axiom checks (Reserved — never emitted today; see §5.7)

So at this task: 4 active codes (FORBIDDEN_FUNCTION, SIDE_EFFECT_IN_GUARD, TYPE_INCOMPATIBLE, UNDEFINED_VAR) + 9 reserved codes scaffolded. Total surface: 13 codes named.

§11. Risk register

Risk Mitigation
Confusing EffectCall and FuncCall in the walker One traversal helper that visits both call shapes; tests cover both for forbiddenFunctions.
Type inference over unknown becoming a black hole Explicit unknown propagation rule in §5.4 — never errors when one side is unknown. Tested in F4 with $untyped + 5 (no error) and 5 + "foo" (error).
Walker recursion stack on very deep ASTs The parser caps rules at 10000 nodes. Worst-case nesting depth is bounded by tree shape; iterative traversal with a manual stack is overkill at 10k. Use plain recursion.
Overly strict sideEffectsInGuard — could falsely reject the eight κ builtins (min, max, etc.) Documented as an explicit conservative scope decision. Future loosening is non-blocking.
Walker mutating AST by accident (e.g. node.errors = [...]) Read-only invariant tested explicitly via deep-equal pre/post fixture (F8 in §13). All accumulators are local.
BLOCKLIST and IN_SCOPE_ROOTS drift from the source-of-truth concept docs Constants exported from validator; tests pin against literal expected values; comments link to concept docs.
noUncheckedIndexedAccess in tsconfig Every arr[i] accessed via arr[i]! post-bounds-check, or by destructuring, never raw.
path field becoming complex to assemble Helper pathExt(parent, segment) returns a new array (immutable extension). Walker carries path by value through every recursive call.

§12. Estimated implementation

Step Lines (rough)
validator.ts JSDoc + types ~80
validator.ts constants (BLOCKLIST, IN_SCOPE_ROOTS) ~20
validator.ts walkers (one per check) ~250
validator.ts axiom stubs (7 functions) ~50
validator.ts validate() composer ~30
validator.ts total ~430
validator.test.ts test helpers ~50
validator.test.ts 9 fixture groups (F1–F9) + edge cases ~450
validator.test.ts total ~500

Test count target: 30–45 cases spread across F1–F9.

§13. Test matrix (locked in §packet, drafted here)

Fixture Check exercised Expected
F1 forbiddenFunctions now() in expression → 1 error code FORBIDDEN_FUNCTION
F1b forbiddenFunctions read_file() in EffectCall → 1 error
F1c forbiddenFunctions vrf_output VarRef → no error (allowlisted)
F2 sideEffectsInGuard FuncCall in guard condition → 1 error code SIDE_EFFECT_IN_GUARD
F3 mutationOfInput scaffold returns [] (no input parses to a mutation today)
F4 typeCompatibility 5 + "foo" → 1 error code TYPE_INCOMPATIBLE
F4b typeCompatibility $x + 5 → no error (unknown propagates)
F4c typeCompatibility 1 == "1" → 1 error
F4d typeCompatibility not 5 → 1 error (logical-not on int)
F5 scopeCheck $undefined → 1 error code UNDEFINED_VAR
F5b scopeCheck $event.id → no error (event is in-scope root)
F6 cycleDetection scaffold returns []
F7 axiomCheck each of 7 stubs returns []; composed call returns []
F8 happy path valid rule (modeled after AcceptCommitment) → {valid: true}
F8b read-only AST is structurally unchanged before/after validate (deep-equal)
F9 multi-error one rule with 3 distinct issues → errors.length === 3, no short-circuit
F9b multi-error 4-issue rule → 4 errors (proves further)

Plus structural assertions per fixture: code values, path arrays present, location populated, message includes context.

§14. Pre-flight verification

  • Worktree created at .worktrees/claude/p1-2-3-validator off origin/main 7218b34bdc4ddd1ad5651b16d99c70b25e75d4d4 (commit 7218b34b feat(p1-2-2-parser): Chevrotain parser + 11-node AST (R84 κ Wave 3) (#205)).
  • Branch feature/p1-2-3-validator set up to track origin/main.
  • npm ci --cache .npm-cache installs cleanly with the inherited lockfile (no new deps for this task).
  • Parser module readable; AST surface mapped (§4).
  • Forbidden-ops list, in-scope variables, axiom set codified (§5).
  • 7-check composition + aggregate-don’t-short-circuit semantics codified (§6).
  • ValidationError shape locked (§7).
  • Read-only invariant marked (§8); will be verified in F8b.
  • ADR-006-dsl-grammar drift not in scope (§3).

Next step: contract (Step 2 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.