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:

  1. θ consensus votes — arbiters sign (round_id, merkle_root, rule_version_hash); a mismatch prevents a vote from being counted.
  2. ι fork idsfork_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_old and v_new.
  • Compute effect-set hashes per event: h = SHA-256(canonical(effects)).
  • Activation proceeds only if h_old == h_new for 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 if and 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


Back to top

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

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