S12 — DSL

Domain-specific language for writing rules. Deterministic, integer-only, compiles to WASM. This spec defines the grammar, type system, builtin functions, evaluation semantics, and compilation target of the DSL.

Example rule

rule CommitmentCreated {
  guard:
    event.type == "A01"
    AND event.actor.stake >= MIN_STAKE

  effects:
    stake.freeze(event.actor.id, event.stake_amount)
    obligation.assign(event.actor.id, "deliver", event.deadline)
    token.emit(event.actor.id, L0, event.context)
}

EBNF grammar

rule_file     ::= rule_def+

rule_def      ::= "rule" ident "{" guard_block effects_block "}"

guard_block   ::= "guard:" expression (("AND" | "OR") expression)*

expression    ::= field_access comparison literal
                | "bps(" expression "," literal ")"
                | funcall

field_access  ::= ident ("." ident)+             (* e.g., event.actor.stake *)

comparison    ::= "==" | "!=" | "<" | ">" | "<=" | ">="

effects_block ::= "effects:" effect+

effect        ::= effect_domain "." method "(" args ")"

effect_domain ::= "state" | "stake" | "reputation"
                | "obligation" | "token" | "finality"

args          ::= expression ("," expression)*

literal       ::= INT | STRING | BOOL

funcall       ::= ident "(" args ")"

ident         ::= [a-zA-Z_][a-zA-Z0-9_]*

Parsing is LL(1) on a whitespace-tolerant stream. Chevrotain (the chosen parser combinator, per Phase 0 stack) implements this grammar directly.

Type system

Five primitive types. No Float. Floating-point is forbidden by s01-constitution to preserve deterministic replay.

Type Notes
Int64 64-bit signed integer. Default numeric type. Overflow = runtime error, not wraparound.
BasisPoints Type-alias of Int64. Constructed via bps(value, rate_in_bps). 5% = 500bp.
Epoch Type-alias of Int64. Ticks of the round clock (s06-consensus). Not wall-clock time.
String UTF-8, maximum 256 bytes. Compared byte-wise.
Bool true / false.

Arrays are fixed-size only ([Int64; 8], [Int64; 16], [Int64; 32]). No dynamic arrays, no strings in arrays. This keeps the compiled wasm’s memory footprint statically known.

Type-inference pseudocode

fn infer(expr) -> Type:
    match expr:
        literal_int           -> Int64
        literal_str           -> String
        literal_bool          -> Bool
        field_access(name)    -> lookup(context_schema, name)
        bps(v, rate)          -> BasisPoints
        binop(op, lhs, rhs)   -> unify(infer(lhs), infer(rhs))
        funcall(f, args)      -> signature_of(f).return_type

unify succeeds when types are equal or when one is BasisPoints and the other is Int64 (coercion in that direction only). All other mismatches are compile-time errors; the parser halts before any wasm is emitted.

Builtin functions

A closed set. No FFI. No user-defined functions in v1 of the DSL.

Function Signature Purpose
bps(value, rate_in_bps) (Int64, Int64) -> Int64 Basis-point multiplication: (value * rate) / 10000
isqrt(n) (Int64) -> Int64 Integer square root, floor
ilog2(n) (Int64) -> Int64 Integer base-2 logarithm, floor
min(a, b) (Int64, Int64) -> Int64 Minimum
max(a, b) (Int64, Int64) -> Int64 Maximum
abs(a) (Int64) -> Int64 Absolute value
concat(a, b) (String, String) -> String Concatenation (result bounded to 256 bytes; overflow = compile error when both sides are literals, runtime error otherwise)
signed(hash_hex, pubkey) (String, String) -> Bool Signature verify against a registered pubkey

Explicitly forbidden, with reason:

  • rand() — non-determinism breaks replay
  • now() — wall-clock non-determinism; use Epoch values from the event
  • read_file(), network, environment — sandbox escape
  • clock gettime, system calls — same

Evaluation semantics

Rules are ordered by:

specificity_score = count(guard.conditions) * 10 + priority_bps
  • More specific guards win over general ones (higher condition count → higher score).
  • Ties are broken by priority_bps declared in the rule frontmatter (default 0).
  • First match wins — no fallthrough.
  • Effects of the matched rule are applied atomically within a single database transaction. If any effect fails, the entire transaction rolls back and the event is rejected.

WASM compilation target

Each rule compiles to a standalone wasm module with two exported functions:

  • check(ctx: i64) -> i32 — guard evaluation; returns 1 if the guard holds, 0 otherwise.
  • apply(ctx: i64) — sequence of host calls implementing the effects.

The ctx parameter is a pointer into a host-owned arena containing the event and the actor state. Host functions are whitelisted per effect domain; this whitelist is the wasm-level guarantee that rules cannot escape their sandbox.

Worked compilation example

AcceptCommitment rule DSL:

rule AcceptCommitment {
  guard:
    event.type == "A01"
    AND host.stake_available(event.actor.id) >= event.stake_amount

  effects:
    stake.freeze(event.actor.id, event.stake_amount)
    state.transition(event.id, 1)
}

Compiles to (pseudo-wat):

(module
  (import "host" "stake_available"
          (func $host.stake_available (param i64) (result i64)))
  (import "host" "stake_freeze"
          (func $host.stake_freeze (param i64 i64)))
  (import "host" "state_transition"
          (func $host.state_transition (param i64 i32)))
  (import "host" "event_type_eq"
          (func $host.event_type_eq (param i64 i32) (result i32)))
  (import "host" "event_actor_id"
          (func $host.event_actor_id (param i64) (result i64)))
  (import "host" "event_stake_amount"
          (func $host.event_stake_amount (param i64) (result i64)))
  (import "host" "event_id"
          (func $host.event_id (param i64) (result i64)))

  (func (export "check") (param $ctx i64) (result i32)
    (local $actor_id i64)
    (local $stake_req i64)
    (if (i32.eqz (call $host.event_type_eq (local.get $ctx) (i32.const 0xA01)))
        (then (return (i32.const 0))))
    (local.set $actor_id (call $host.event_actor_id (local.get $ctx)))
    (local.set $stake_req (call $host.event_stake_amount (local.get $ctx)))
    (i64.ge_s (call $host.stake_available (local.get $actor_id))
              (local.get $stake_req)))

  (func (export "apply") (param $ctx i64)
    (call $host.stake_freeze
          (call $host.event_actor_id (local.get $ctx))
          (call $host.event_stake_amount (local.get $ctx)))
    (call $host.state_transition
          (call $host.event_id (local.get $ctx))
          (i32.const 1))))

The compilation cache keys on rule_content_hash = SHA-256(source_text); identical DSL sources always produce identical wasm bytes.

Evaluation budget

Each rule invocation has a hard budget of 10,000 wasm instructions. The host counts instructions and halts the module if the budget is exceeded; the enclosing event is rejected with ADMISSION_DENIED and the overrun is recorded as a ζ thought record (thought_type: "rule_budget_exceeded"). Per-rule cost is tracked over time; rules whose p99 cost exceeds 5,000 are flagged for review.

Error reporting

Three error classes, all surfaced through ζ (thought_record):

Class When ζ thought_type Payload
Parse error DSL does not satisfy EBNF parse_error { rule_file, line, column, expected_token, found_token }
Type error inference/unification fails type_error { rule_id, expr, inferred_type, expected_type }
Runtime error budget exceeded or host call trap rule_budget_exceeded or rule_trap { rule_id, ops_count, trap_reason }

Every error chains back to the governance process: a rule that never parses cannot enter production; a rule that type-errors is rejected at ADR review; a rule that traps at runtime is candidate for emergency deactivation under s19-governance.

Versioning and test corpus

Each rule set carries a version. When a rule changes:

  1. The new version v_new is compiled alongside the live v_old.
  2. Both are replayed against a test corpus of past events (kept in docs/reference/rule-corpus/ once Phase 1 ships).
  3. The effect sequences must be byte-identical, unless the ADR authorizing the change explicitly lists divergent cases.
  4. Activation (cutover from v_old to v_new) requires supermajority per s19-governance §rule-versioning.

This protects against silent drift: even a well-intentioned rewrite cannot quietly change outcomes for past-shape events.

Phase 0 posture

No DSL parser, no compiler, no wasm runtime exist in Phase 0. Rules in Phase 0 are hand-written TypeScript checks inside src/domains/*/handlers.ts. The DSL activates in Phase 1 (R81+) when the rule-engine domain ships. Until then, spec-to-code traceability is a manual review step in the 5-step executor chain, step 5 (verify).

Cross-references

  • s11-rule-engine — the engine that hosts the wasm modules
  • s19-governance §rule-versioning — governance around DSL upgrades
  • architecture/decisions/ADR-006-rule-engine.md — decision to use Chevrotain + wasm
  • docs/3-world/physics/laws/rule-engine.md — narrative counterpart

Implementation Status

Verified against source: 2026-04-16

Claim Status Notes
EBNF grammar Spec-only No parser exists. Chevrotain is in the Phase 0 stack but unwired.
Type system (Int64, BasisPoints, Epoch, String, Bool) Spec-only No type checker.
Builtin functions (bps, isqrt, ilog2, …) Spec-only No runtime implementations.
Evaluation semantics (specificity, first-match, atomic) Spec-only No evaluator.
WASM compilation target Spec-only No wasm emission pipeline.
10,000-instruction budget Spec-only No instruction counter.
Error reporting through ζ Spec-only ζ is targeted for P0.7; no DSL errors flow yet.
Versioning + test corpus Spec-only No corpus directory yet.

Summary: S12 is a pure protocol specification. Colibri’s Phase 0 codebase is a 14-tool MCP server with handwritten handlers (ADR-004 R75 Wave H amendment). The DSL ships in Phase 1 (R81+).


Back to top

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

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