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 replaynow()— wall-clock non-determinism; useEpochvalues from the eventread_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_bpsdeclared 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:
- The new version
v_newis compiled alongside the livev_old. - Both are replayed against a test corpus of past events (kept in
docs/reference/rule-corpus/once Phase 1 ships). - The effect sequences must be byte-identical, unless the ADR authorizing the change explicitly lists divergent cases.
- Activation (cutover from
v_oldtov_new) requires supermajority pers19-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 moduless19-governance§rule-versioning — governance around DSL upgradesarchitecture/decisions/ADR-006-rule-engine.md— decision to use Chevrotain + wasmdocs/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+).