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 SHA7218b34b). 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 node interfaces:
- AST shape recap (11 nodes, plain data, all carry
typediscriminant +location: Location):RuleNode { name, guards: GuardClause[], effects: EffectCall[] }— top-levelGuardClause { condition: Expression | null, action: 'admit' | 'reject', reason: string | null }—condition === nullforelseclausesEffectCall { function: string, args: Expression[] }— invocation by nameBinaryOp { op, left: Expression, right: Expression }— arithmetic + comparisonUnaryOp { op: '-', operand: Expression }— numeric negationLogicalOp { op: 'and' | 'or' | 'not', operands: Expression[] }— boolean combinatorsIntLiteral { value: bigint }— integer constant (bigint carrier; int64 envelope downstream)BoolLiteral { value: boolean }— true / falseStringLiteral { value: string }— decoded; valid only inside Effect/Func args + reject reasonVarRef { path: string[] }—$a.b.c→['a', 'b', 'c']FuncCall { name: string, args: Expression[] }— built-in invocation in expression position
- Critical observation about
EffectCallvsFuncCall: Both carry an unrestrictedstringname. They are syntactically identical to the parser; only their position distinguishes them:EffectCalllives directly undereffects { ... }blocks;FuncCalllives in expression position (under guards, under nested arguments, etc.).- The forbidden-functions check applies to BOTH — a
FuncCallnamednowinside a guard is non-deterministic, and anEffectCallnamedread_fileinside 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'.
- The forbidden-functions check applies to BOTH — a
- 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" + 5as aBinaryOp{op:'+', left: StringLiteral, right: IntLiteral}becauseStringLiteralis in theExpressionunion (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:
IntLiteral→intBoolLiteral→boolStringLiteral→stringVarRef→unknown(untyped at this stage)FuncCall→unknown(until P1.3.1 evaluator tightens)-
UnaryOp{'-'}requires operandint | unknown→intif int,unknownif unknown, ERROR if stringbool LogicalOp{'and' | 'or' | 'not'}requires every operandbool | unknown→bool, ERROR otherwise-
BinaryOp{arithmetic '+' '-' '*' '/' '%'}requires bothint | unknown→int, ERROR if stringbool participates BinaryOp{equality '==' '!='}requires both same type or one unknown →bool, ERROR if mixed concrete (e.g.,int == string)BinaryOp{ordering '<' '>' '<=' '>='}requires bothint | unknown→bool, 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 evaluationactor— bound byprocess_action(actor=...)per extraction §8stake— stake namespacereputation— reputation namespacetoken— token namespacestate— generic state namespaceobligation— obligation namespacefinality— finality namespacevrf_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-validatorofforigin/main7218b34bdc4ddd1ad5651b16d99c70b25e75d4d4(commit7218b34b feat(p1-2-2-parser): Chevrotain parser + 11-node AST (R84 κ Wave 3) (#205)). - Branch
feature/p1-2-3-validatorset up to trackorigin/main. npm ci --cache .npm-cacheinstalls 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).