Rule Engine (κ)
κ is the deterministic evaluator for every rule the system enforces — admission limits, rate caps, decay schedules, reputation penalties, arbiter bounds. κ rules are source-controlled, version-hashed, and evaluated with pure integer math. Two arbiters with the same κ version and the same input must produce bit-identical output; this is what makes κ acceptable as input to θ consensus.
Phase 0 reality: κ is specified and partially scaffolded. The DSL is defined; the evaluator is not yet written. Rules in Phase 0 are applied by hand in the code paths that need them, with a placeholder for κ once it ships.
Authoritative specs: ../../../spec/s11-rule-engine.md and ../../../spec/s12-dsl.md. Admission-specific rules live in ../../../spec/s10-admission.md.
Integer-only arithmetic
κ does no floating-point math. Everything is 64-bit integers in basis points — units of 1 / 10000. So:
10000= 100%2500= 25%1= 0.01%
A reputation decay of “1.5% per epoch” is written as 150 bps, evaluated as value - (value * 150 / 10000), and yields the same result on every arbiter.
Floating-point is forbidden in rule bodies. It produces platform-dependent results and would break consensus.
Basis-point arithmetic — examples
| Expression | Value | Reading |
|---|---|---|
bps_mul(1000, 500) |
50 |
1000 × 5% = 50 |
bps_mul(10000, 10000) |
10000 |
100% × 100% = 100% (identity) |
bps_div(5000, 2500) |
20000 |
5000 ÷ 25% = 20000 |
bps_pct(3750) |
"37.50%" |
display helper — rendering only, not consensus-visible |
decay(1000, 150, 1) |
985 |
1000 after one epoch of 1.5% decay |
decay(1000, 150, 2) |
970 |
compounded: 1000 → 985 → 970 (floor at each step) |
All operators round via floor, not banker’s rounding. Floor is chosen because it is monotone under composition — decay(decay(x, r, 1), r, 1) == decay(x, r, 2) up to at most one unit of drift, and the drift is deterministic-given-inputs.
Forbidden operations
Rule bodies cannot invoke any of the following, because any of them would make evaluation non-deterministic:
- Clock reads. Time enters κ only via explicit inputs passed by the caller.
- Randomness. κ has no RNG. Where randomness is needed (e.g. leader election), the engine consumes a precomputed VRF output as an input. See ADR-002.
- Floating-point arithmetic. See above.
- Side effects. κ functions are pure: inputs in, value out. No writes, no network, no logging. Logging happens at the enclosing α layer.
- External I/O. No file reads, no HTTP.
Built-in functions
κ exposes eight built-ins, all integer-only:
| Function | Purpose |
|---|---|
min(a, b) |
Minimum of two integers |
max(a, b) |
Maximum of two integers |
sqrt(x) |
Integer square root (floor) |
log2(x) |
Integer floor of log base 2 |
abs(x) |
Absolute value |
cap(x, ceiling) |
min(x, ceiling), spelled explicitly |
decay(value, rate_bps, epochs) |
Deterministic exponential decay in basis points |
diminishing(x, curve) |
Diminishing-returns transform over a precomputed curve |
Anything else is a DSL-level syntax error. The list is intentionally small and grows only via ADR.
DSL grammar (EBNF fragment)
The κ DSL is an expression language over integers plus a narrow effect API. Rules are data; no host-language escape hatch.
rule ::= "rule" NAME "{" guard effects "}" ;
guard ::= "guard:" expression ( "and" expression )* ;
effects ::= "effects:" effect+ ;
effect ::= target "." method "(" arglist? ")" ;
target ::= "stake" | "reputation" | "token" | "state" | "obligation" | "finality" ;
expression ::= comparison | builtin_call ;
comparison ::= term op term ;
op ::= "==" | "!=" | "<" | "<=" | ">" | ">=" ;
term ::= INT | identifier | builtin_call ;
builtin_call ::= ("min"|"max"|"sqrt"|"log2"|"abs"|"cap"|"decay"|"diminishing") "(" arglist ")" ;
arglist ::= term ( "," term )* ;
NAME ::= [A-Z][A-Za-z0-9_]* ;
identifier ::= [a-z][a-z0-9_]* ;
INT ::= "-"? [0-9]+ ; (* no float, no hex, no underscore separators *)
Explicit omissions: no string literals, no user-defined functions, no for/while, no new, no import. A ruleset is a sequence of rule declarations evaluated top-down (see rule application algorithm below).
Worked rule — AcceptCommitment
rule AcceptCommitment {
guard:
event.type == "COMMITMENT_REQUEST"
and event.status == "PENDING"
and stake.available(event.actor) >= event.amount
and reputation.score(event.actor, "commissioning") >= 100
effects:
state.transition(event.id, from="PENDING", to="ACCEPTED")
stake.freeze(event.actor, event.amount)
obligation.assign(event.actor, event.id, deadline=event.deadline)
}
Reading the rule: the guard states four preconditions joined by and; if every one evaluates true, the effects fire atomically (all three land in a single logical event application, or none do — transactional at the β layer, see ../../execution/task-pipeline.md).
Rule application algorithm
fn apply(event, ruleset):
matches = []
for rule in ruleset.sorted_by_specificity():
if evaluate_guard(rule.guard, event):
matches.append(rule)
break # first-match-wins; no fallthrough
if matches.is_empty():
return ADMISSION_DENIED("no rule matched")
rule = matches[0]
try:
apply_effects_atomically(rule.effects, event)
return ADMISSION_ACCEPTED(rule.name)
except BudgetExceeded:
return ADMISSION_DENIED("rule budget exceeded")
except EffectInvariantViolated as e:
return ADMISSION_DENIED(e.reason)
Specificity ordering: rules are sorted by (a) guard term count descending, then (b) declaration order. This gives stable, deterministic ordering regardless of storage format. Ties within a ruleset are a load-time error — κ refuses to boot with ambiguous matches.
Rule versioning
Every κ ruleset has a rule_version_hash — SHA-256 over the canonical serialization of the rule bodies plus the engine version. This hash participates in two load-bearing places:
- θ consensus votes — arbiters sign
(round_id, merkle_root, rule_version_hash); a mismatch prevents a vote from being counted. - ι fork ids —
fork_id = SHA-256(parent_fork_id || divergence_event || rule_version_hash || reason).
This means a silent rule change is impossible: either all arbiters upgrade together, or the upgrade creates a fork under RULE_UPGRADE.
Test corpus parity requirement
Before a new rule_version_hash may be activated, π governance requires a parity run against the standing test corpus:
- Evaluate every corpus event under
v_oldandv_new. - Compute effect-set hashes per event:
h = SHA-256(canonical(effects)). - Activation proceeds only if
h_old == h_newfor every corpus event where both versions admit the event, and the divergence set (events admitted under one version but not the other) matches the proposal’s declared scope.
A divergence outside the declared scope automatically fails the π proposal at stage 1. See ../enforcement/governance.md §versioning.
Constitutional axioms
κ does not evaluate in a vacuum. Every rule is subject to seven constitutional axioms, listed in ../constitution.md and encoded as AX-01 through AX-07. Axioms are rules about rules: they bound what κ may be asked to evaluate. An attempt to load a ruleset that violates an axiom is rejected at boot before any rules are registered.
Admission layer
Admission — whether a given tool call is allowed at all — is a κ evaluation that runs at α’s tool-lock stage (stage 1 of the 5-stage chain tool-lock → schema-validate → audit-enter → dispatch → audit-exit). The inputs are: caller identity, tool name, current mode, reputation snapshot, and rule version. The output is admit-or-deny with a typed reason.
See ../../../spec/s10-admission.md for admission-specific rules.
Evaluation budget
A κ evaluation is bounded: it has a maximum instruction count (enforced by the engine) and a maximum call depth. Exceeding either is a deterministic RuleBudgetExceeded failure — the same on every arbiter, consistent with consensus. The bounds themselves are part of the rule version hash.
Default budget constants
| Bound | Phase 0 default | Scope |
|---|---|---|
MAX_INTEGER_OPS |
10_000 |
Total integer ops across guard + effects (combined) |
MAX_CALL_DEPTH |
16 |
Nested builtin_call frames |
MAX_ARG_COUNT |
8 |
Arity of any single builtin_call |
A rule exceeding any of these deterministically returns ADMISSION_DENIED(reason="budget:<which>"). Budget overruns never silently truncate — κ fails loud so admission denials remain interpretable.
What κ is not
- Not a full programming language. The DSL is intentionally restricted; control flow is
ifand pattern-match only; no unbounded recursion. - Not an LLM prompt. Rules are deterministic integer math. LLM-mediated decisions (if any) happen outside κ and feed their outputs in as explicit inputs.
- Not a scheduler. κ says whether something is allowed; β decides when it runs.
Phase 0 posture
- Integer-math and basis-point conventions apply to any Phase 0 rule-shaped code written today (e.g. ad-hoc admission checks).
- The DSL is defined but the parser and evaluator are not shipped.
- No tool in the Phase 0 tool surface exposes κ directly; rules are internal to α’s
tool-lock. - Rules that would be DSL-defined in a later phase are applied in code paths by hand, using the basis-point conventions above so migration to the DSL is a textual rewrite, not a behavioural change.
- First real κ activation is targeted for Phase 1 (R81+ per
../../../5-time/roadmap.md); it is not a Phase 0 P0.* sub-task.
See also
../constitution.md— the seven axioms (AX-01–AX-07)consensus.md— θ, which signs the rule version hashstate-fork.md— ι, whose fork ids include the rule version hash../enforcement/governance.md— π, the process for approving rule upgrades../../../spec/s10-admission.md— admission rules../../../spec/s11-rule-engine.md— authoritative rule-engine spec../../../spec/s12-dsl.md— authoritative DSL spec../../../architecture/decisions/ADR-002-vrf-implementation.md— VRF for precomputed randomness../../../architecture/decisions/ADR-006-dsl-grammar.md— DSL parser technology choice (Chevrotain)