@statedelta-actions/rules
v0.2.0
Published
Rule evaluation engine with JIT-optimized sequential evaluation and sub-rules
Readme
@statedelta-actions/rules
Rule Engine — superset layer on top of ActionEngine. Conditional trigger execution by priority.
Rule = trigger. Events are a separate package (
@statedelta-actions/events).
Table of Contents
- Philosophy
- Architecture Overview
- Installation & Setup
- Quick Start
- Rule Definition
- Registration
- Trigger Evaluation
- Sub-rules (Conditional Cascade)
- Hooks (Governance)
- Middleware (Params Enrichment)
- JIT Compilation
- Batch Operations
- Async Support
- Error Handling
- HALT_HANDLER
- createInvokerMiddleware
- Full API Reference
- Use Case: RPG Combat Tick
- Use Case: E-commerce Order Pipeline
- Use Case: Deployment Approval Workflow
- Performance Spectrum
- Internal Design Notes
Philosophy
Rule = Trigger
A rule is not an executor. A rule is an invoker — a conditional trigger that, when matched, invokes an action through the ActionEngine. The RuleEngine does not execute directives. It normalizes rules into hidden actions at registration time, then delegates all execution to the ActionEngine.
Registration:
rule { id: "combat-heal", when: ..., then: [...] }
-> actionEngine.register([{ id: "rule:combat-heal", directives: [...] }])
Evaluation:
when(ctx) === true
-> actionEngine.invoke("rule:combat-heal", params)After registration, the RuleEngine is a loop of when() -> invoke().
RuleEngine receives, does not create
The RuleEngine receives an IActionEngine<TCtx> already instantiated and configured. It does not inject handlers, does not manipulate the access manifest, does not create the ActionEngine. The consumer is responsible for:
- Registering handlers (
dispatch,emit,halt, custom) - Setting limits, JIT mode
- Configuring the ActionAnalyzer separately if static analysis is needed
This separation means the same ActionEngine can serve both direct action invocations and rule-driven invocations.
Hooks = governance, handlers = flow control
Hooks (beforeRule, afterRule) govern the rule loop: guards, observation, abort decisions. Flow control inside an action (halt, state locking, error abort) belongs to handlers in the ActionEngine. Two different layers with different responsibilities.
Middleware = params enrichment, not ctx transformation
Middleware enriches the params envelope (execution scope), not the domain ctx. The ctx (TCtx) is domain state owned by the consumer — read-only for middleware.
Never throws during evaluation
The engine never throws to the consumer during evaluate(). All runtime errors are collected in RuleEvaluationResult.errors. The only exception is a programmer error: calling evaluate() when isAsync === true.
Feature not used = zero overhead
When hooks are not registered, hook code is never executed — not as dead branches, but as absent code paths. The JIT compiler conditionally emits code only for features that are actually configured. Zero middleware means no middleware variables, no pipeline calls, no params allocation.
Architecture Overview
Monorepo Position
@statedelta-actions/core <- shared types, slots, frame
|
@statedelta-actions/actions <- ActionEngine — runtime puro (directives, handlers, JIT)
|
@statedelta-actions/rules <- RuleEngine (this package)
@statedelta-actions/events <- EventProcessor (separate package)
@statedelta-actions/graph <- dependency graph (consumed by analyzer, not by actions/rules)
@statedelta-actions/analyzer <- ActionAnalyzer — static analysis, capabilities (opt-in)
(future) tick-runner / realm <- PPP: evaluate -> drain events -> repeatComposition Model
Consumer (tick-runner, game loop, business layer)
|
+-- configures ActionEngine (handlers, limits)
+-- creates RuleEngine(actionEngine, middleware, hooks)
+-- registers rules
+-- calls evaluate(ctx)Module Structure
src/
+-- types.ts <- All types and interfaces + RuleEvaluatorFn
+-- engine.ts <- RuleEngineImpl + createRuleEngine + registry helpers (~470 lines)
+-- validate.ts <- validateRule, validateSubRule
+-- handlers.ts <- HALT_HANDLER
+-- middleware.ts <- createInvokerMiddleware (public) + runMiddleware (internal)
+-- index.ts <- Public re-exports
+-- eval/ <- Evaluation runtime
| +-- interpreter.ts <- Rule evaluation interpreter sync/async + result builders
| +-- jit.ts <- buildRuleExecutor + emitReturn codegen
| +-- sub-rules.ts <- evaluateSubRulesSync/AsyncPublic Exports
// Factory
export { createRuleEngine } from "./engine";
// Handlers
export { HALT_HANDLER } from "./handlers";
// Middleware
export { createInvokerMiddleware } from "./middleware";
// Types
export type {
SubRuleDefinition,
RuleDefinition,
RuleRegisterResult,
RuleRegisterError,
RuleEvaluationResult,
RuleEvaluationContext,
RuleEngineConfig,
IRuleEngine,
RuleHooks,
RuleMiddleware,
} from "./types";Installation & Setup
import { createActionEngine } from "@statedelta-actions/actions";
import {
createRuleEngine,
HALT_HANDLER,
createInvokerMiddleware,
} from "@statedelta-actions/rules";
// 1. Configure ActionEngine with your handlers
const actionEngine = createActionEngine<MyCtx>({
handlers: {
dispatch: myDispatchHandler,
emit: myEmitHandler,
halt: HALT_HANDLER,
},
});
// 2. Create RuleEngine, passing the ActionEngine
const ruleEngine = createRuleEngine<MyCtx>({
actionEngine,
middleware: [createInvokerMiddleware()], // optional
ruleHooks: { // optional
beforeRule: (rule, evalCtx) => { /* guard */ },
afterRule: (rule, result, evalCtx) => { /* observe */ },
onRulesComplete: (result) => { /* cleanup */ },
},
maxSubRuleDepth: 10, // default
mode: "auto", // "interpret" | "jit" | "auto" (default)
autoJitThreshold: 8, // default
});Quick Start
// Register rules
ruleEngine.register([
{
id: "heal-when-low",
priority: 100,
when: (ctx) => ctx.hp < 50,
then: [
{ type: "dispatch", target: "hp", op: "inc", value: 20 },
],
},
{
id: "regen-mp",
priority: 50,
when: (ctx) => ctx.mp < 100,
then: [
{ type: "dispatch", target: "mp", op: "inc", value: 5 },
],
},
]);
// Evaluate all trigger rules against current state
const result = ruleEngine.evaluate(ctx);
// result.success -> true if completed without abort
// result.matched -> ["heal-when-low", "regen-mp"] rule IDs
// result.counters -> { rulesEvaluated: 2, rulesMatched: 2, ... }Rule Definition
SubRuleDefinition
Base type for sub-rules. No priority — sub-rules execute in declaration order.
interface SubRuleDefinition<TCtx> {
readonly id: string; // unique identifier
readonly when?: (ctx: TCtx) => boolean; // trigger condition
readonly then?: readonly Directive<TCtx>[]; // action directives
readonly rules?: readonly SubRuleDefinition<TCtx>[]; // nested sub-rules
readonly tags?: readonly string[];
readonly effects?: readonly string[];
readonly declarations?: Record<string, unknown>;
readonly metadata?: Record<string, unknown>;
}RuleDefinition
Top-level rules extend SubRuleDefinition with mandatory priority.
interface RuleDefinition<TCtx> extends SubRuleDefinition<TCtx> {
readonly priority: number; // higher = first (desc order)
readonly rules?: readonly SubRuleDefinition<TCtx>[]; // sub-rules (no priority)
}A rule must have when (trigger condition).
A rule must have then or rules (or both). A rule with only rules and no then is a group gate — a pure conditional container.
Priority
Higher number = executes first (like z-index). Rules are sorted by priority descending at registration time. Sub-rules execute in declaration order — they don't have priority.
Validation
| Check | Error Code |
|-------|-----------|
| id must be a non-empty string | INVALID_RULE |
| priority must be a number | INVALID_RULE |
| Must have when function | INVALID_RULE |
| Must have then or rules (or both) | INVALID_RULE |
| Duplicate ID | DUPLICATE_ID |
Sub-rule validation:
| Check | Error Code |
|-------|-----------|
| id must be a non-empty string | INVALID_RULE |
| Must have then or rules (or both) | INVALID_RULE |
Registration
const result = ruleEngine.register([rule1, rule2, ...]);Registration pipeline
- Validate each rule (id, priority, when, then/rules)
- Normalize to hidden action:
rule:{id} - Collect sub-rules recursively with dot-separated IDs
- Index in internal registries (
_rulessorted by priority desc) - Register hidden actions in ActionEngine
- Return merged result
RuleRegisterResult
interface RuleRegisterResult {
readonly registered: readonly string[];
readonly errors: readonly RuleRegisterError[];
readonly warnings: readonly RegisterWarning[];
}Unregister
const removed = ruleEngine.unregister("rule-id"); // true if found and removedRemoves the rule from all internal registries, unregisters the hidden action from ActionEngine, and recursively cleans up sub-rules.
Trigger Evaluation
const result = ruleEngine.evaluate(ctx);Processes all rules in priority descending order:
evaluate(ctx)
|
actionEngine.setContext(ctx)
|
for each rule (priority desc):
|
+-- beforeRule hook -> "skip" -> skipped | "abort" -> return | void -> continue
+-- when(ctx) -> false -> notMatched | error -> collect, notMatched
+-- matched
+-- Middleware pipeline -> params (or error -> collect, skip invoke)
+-- Invoke action (if has then) -> DirectiveResult
+-- afterRule hook -> "abort" -> return | void -> continue
+-- Halt check -> aborted -> return
+-- Sub-rules cascade -> aborted -> return
|
onRulesComplete(result)
return resultRuleEvaluationResult
interface RuleEvaluationResult {
readonly success: boolean; // true if completed without abort
readonly aborted: boolean; // true if stopped early
readonly abortedBy?: string; // "beforeRule" | "afterRule" | "sub-rule" | "halt" | handler
readonly matched: readonly string[]; // IDs of matched rules
readonly skipped: readonly string[]; // IDs skipped by beforeRule
readonly notMatched: readonly string[];
readonly errors: readonly RuleError[];
readonly processedCount: number; // rules processed (< totalCount on abort)
readonly totalCount: number;
readonly counters: FrameCounters; // accumulated across all nesting
}Example
const ruleEngine = createRuleEngine({ actionEngine });
ruleEngine.register([
{
id: "shield",
priority: 200,
when: (ctx) => ctx.inCombat,
then: [{ type: "dispatch", target: "defense", op: "inc", value: 10 }],
},
{
id: "heal",
priority: 100,
when: (ctx) => ctx.hp < 50,
then: [{ type: "dispatch", target: "hp", op: "inc", value: 20 }],
},
]);
const ctx = { inCombat: true, hp: 30, defense: 0 };
const result = ruleEngine.evaluate(ctx);
// shield (200) executes first, then heal (100)
// ctx.defense === 10, ctx.hp === 50
// result.matched === ["shield", "heal"]
// result.counters.rulesMatched === 2Sub-rules (Conditional Cascade)
Sub-rules enable conditional branching within a rule. After the parent's then execute, each sub-rule's when() is evaluated in declaration order. Sub-rules can nest to arbitrary depth (bounded by maxSubRuleDepth, default 10).
Registration
Sub-rules are registered recursively as hidden actions with dot-separated IDs:
rule:combat-heal <- parent
rule:combat-heal.low-hp <- sub-rule
rule:combat-heal.low-hp.crit <- nested sub-ruleGroup gates (no then) register no action — actionId = "".
Evaluation
After parent invoke (or directly for group gate):
for each sub-rule (declaration order):
+-- when(ctx) -> false -> skip | undefined -> unconditional
+-- Invoke (if has then) -> aborted -> propagate up
+-- Recurse (if has sub-rules) -> aborted -> propagate upGroup gates
A group gate is a rule with rules but no then. It acts as a pure conditional container:
{
id: "combat-group",
priority: 100,
when: (ctx) => ctx.zone === "combat",
// no `then` — this is a gate
rules: [
{ id: "heal", when: (ctx) => ctx.hp < 50, then: [...] },
{ id: "buff", when: (ctx) => ctx.mp > 0, then: [...] },
],
}The gate evaluates when(). If true, children are evaluated. No action is invoked for the gate itself.
Design decisions
| Decision | Rationale |
|----------|-----------|
| Declaration order, not priority | Sub-rules are a cascade within one rule — order matters semantically |
| Hooks do NOT apply to sub-rules | Hooks govern the main loop, not internal branching |
| Middleware params inherited | Parent's middleware-enriched params propagate as-is |
| Abort propagates upward | Sub-rule halt -> parent aborts -> main loop stops |
| Depth limited | Default 10. Exceeding -> error collected, sub-tree skipped (no abort) |
Example: tiered discounts
ruleEngine.register([
{
id: "discount-engine",
priority: 200,
when: (ctx) => ctx.status === "validated" && ctx.discount === 0,
then: [{ type: "log", message: "evaluating discounts" }],
rules: [
{
id: "vip-discount",
when: (ctx) => ctx.customerTier === "vip",
then: [
{ type: "dispatch", target: "discount", op: "set", value: 0,
resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.2) }) },
],
},
{
id: "gold-discount",
when: (ctx) => ctx.customerTier === "gold",
then: [
{ type: "dispatch", target: "discount", op: "set", value: 0,
resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.15) }) },
],
},
],
},
]);Hooks (Governance)
Hooks are analyzed once at construction time via analyzeSlots. They govern rule evaluation only. Events have their own hooks in @statedelta-actions/events.
Rule Hooks
ruleHooks: {
beforeRule?: (rule: RuleDefinition, evalCtx: RuleEvaluationContext) => "skip" | "abort" | void;
afterRule?: (rule: RuleDefinition, result: DirectiveResult, evalCtx: RuleEvaluationContext) => "abort" | void;
onRulesComplete?: (result: RuleEvaluationResult) => void;
}| Hook | When | Returns | Error behavior |
|------|------|---------|----------------|
| beforeRule | Before when() evaluation | "skip" -> skipped, "abort" -> stop pipeline, void -> continue | Collected in errors[], continue |
| afterRule | After invoke (if rule had then) | "abort" -> stop pipeline, void -> continue | Fire-and-forget |
| onRulesComplete | After all rules processed (or abort) | void | Silenced |
interface RuleEvaluationContext<TCtx> {
readonly ctx: TCtx;
readonly counters: FrameCounters;
}Example: governance by priority
const ruleEngine = createRuleEngine({
actionEngine,
ruleHooks: {
beforeRule: (rule) => {
if (rule.priority < 50) return "skip";
},
},
});Example: abort on error
ruleHooks: {
afterRule: (rule, result) => {
if (result.errors.length > 0) return "abort";
},
},Middleware (Params Enrichment)
Middleware runs between match and invoke. It enriches the params that become the action's scope.
Composition model
Delta-based. Each middleware receives the accumulated params and returns a delta to merge:
params0 = {} -> mw[0](rule, ctx, params0) -> d0 -> params1 = {...params0, ...d0}
mw[1](rule, ctx, params1) -> d1 -> params2 = {...params1, ...d1}
...
invoke(actionId, paramsN)Middleware cannot drop what predecessors injected (only overwrite by key).
Signature
type RuleMiddleware<TCtx> = (
rule: RuleDefinition<TCtx>,
ctx: TCtx,
params: Record<string, unknown>,
) => Record<string, unknown>;Sub-rule middleware
Middleware does not re-run for sub-rules. The parent's middleware-enriched params are passed directly to sub-rule invocations.
Error handling
Middleware error -> collected in errors[], invoke skipped, next rule continues.
Example: custom audit middleware
const auditMiddleware: RuleMiddleware<MyCtx> = (rule, ctx, params) => ({
$audit: {
ruleId: rule.id,
timestamp: Date.now(),
userId: ctx.currentUser.id,
},
});
const ruleEngine = createRuleEngine({
actionEngine,
middleware: [auditMiddleware, createInvokerMiddleware()],
});JIT Compilation
JIT compiles the rule iteration loop, not individual directives (ActionEngine handles directive JIT separately). Two levels of JIT coexist: ActionEngine compiles directive execution, RuleEngine compiles rule orchestration.
What is compiled
buildRuleExecutor generates a function via new Function that replaces the interpreter. Same signature — the engine swaps transparently.
Conditional code emission
The generated JS only contains code for features that are actually configured:
| Feature absent | Code not emitted |
|---|---|
| beforeRule | Entire try/catch block, skip/abort checks |
| afterRule | Entire try/catch block, abort check |
| onRulesComplete | Final call + cleanup blocks |
| No hooks at all | evalCtx not created, no hook variables |
| No middleware | params variable not declared, no pipeline call |
Tier 0 — tight loop
With zero hooks and zero middleware, the generated code is a minimal loop:
for -> when(ctx) -> invoke(actionId) -> halt check -> sub-rules -> nextNo try/catch for hooks. No middleware pipeline. No evalCtx allocation.
Modes
const engine = createRuleEngine({ actionEngine, mode: "auto" });| Mode | Behavior |
|------|----------|
| interpret | Always use interpreter. JIT never activates. compile() is a no-op. |
| jit | Compile immediately at construction. No interpreter phase. |
| auto (default) | Start with interpreter. Promote after threshold calls. |
Default threshold: 8 evaluate() calls. Configurable via autoJitThreshold.
evaluate() #1..#7 -> interpreter
evaluate() #8 -> compile + swap interpreter for JIT
evaluate() #9+ -> JIT compiledcompile()
engine.compile(); // forces immediate promotion to JITUseful for warmup. No-op if mode === "interpret".
compilationMode getter
engine.compilationMode // "interpret" | "jit"Reflects current state — switches from "interpret" to "jit" after promotion.
Batch Operations
Callback-based (recommended)
const result = engine.batch((eng) => {
eng.register([rule1, rule2]);
eng.register([rule3, rule4]);
});
// endBatch() called automatically, even on throwbatch(fn) guarantees cleanup: if fn throws, endBatch() is still called to avoid leaving the engine in an inconsistent state.
Manual
engine.beginBatch();
engine.register([rule1, rule2]);
engine.register([rule3, rule4]);
const result = engine.endBatch();- Delegates
beginBatch()/endBatch()to ActionEngine - Accumulates registration results across multiple
register()calls - Merges everything on
endBatch() - Supports nesting: inner
endBatch()returns empty, outer merges all
Async Support
Detection
const isAsync = ruleHookAnalysis.hasAnyAsync || actionEngine.isAsync;Computed once at construction. Immutable. If any rule hook is async, or if the ActionEngine has async directive hooks, isAsync === true.
Usage
// Sync (throws if isAsync)
const result = engine.evaluate(ctx);
// Async (always works)
const result = await engine.evaluateAsync(ctx);Calling evaluate() when isAsync === true throws an error — must use evaluateAsync(). This is a guard against accidentally dropping promises.
Async hooks
const ruleEngine = createRuleEngine({
actionEngine,
ruleHooks: {
beforeRule: async (rule, evalCtx) => {
const allowed = await checkPermission(rule.id);
if (!allowed) return "skip";
},
afterRule: async (rule, result) => {
await logToExternalService(rule.id, result);
},
},
});
// Must use evaluateAsync
const result = await ruleEngine.evaluateAsync(ctx);Error Handling
The engine never throws during evaluation. All errors are collected:
| Error source | Behavior |
|-------------|----------|
| when() throws | Error collected, rule treated as notMatched, continue |
| beforeRule throws | Error collected, continue (treated as void) |
| afterRule throws | Fire-and-forget, continue |
| onRulesComplete throws | Silenced |
| Middleware throws | Error collected, invoke skipped, next rule |
| ActionEngine invoke errors | Directive errors accumulated in counters |
| Sub-rule when() throws | Error collected, sub-rule skipped |
| maxSubRuleDepth exceeded | Error collected, sub-tree skipped (no abort) |
Inspecting errors
const result = engine.evaluate(ctx);
for (const err of result.errors) {
console.log(`Rule index ${err.ruleIndex}: ${err.error}`);
}
// Consumer decides what errors mean:
if (result.errors.length > 0) {
// handle errors
}HALT_HANDLER
A built-in handler that signals the ActionEngine to abort directive execution. The RuleEngine's interpreter checks abortedBy === "halt" as a controlled stop.
import { HALT_HANDLER } from "@statedelta-actions/rules";
const actionEngine = createActionEngine({
handlers: {
...myHandlers,
halt: HALT_HANDLER,
},
});Usage in directives:
{
id: "guard",
priority: 500,
when: (ctx) => ctx.hp <= 0,
then: [
{ type: "dispatch", target: "status", op: "set", value: "dead" },
{ type: "halt" }, // stops all remaining rules
],
}When halt fires:
result.aborted === trueresult.abortedBy === "halt"result.success === true(halt is a controlled stop, not an error)- Remaining rules are not evaluated
createInvokerMiddleware
Built-in, opt-in middleware that injects $invoker metadata into the action scope:
import { createInvokerMiddleware } from "@statedelta-actions/rules";
const ruleEngine = createRuleEngine({
actionEngine,
middleware: [createInvokerMiddleware()],
});What it injects
{
$invoker: {
ruleId: "combat-heal",
priority: 100,
tags: ["combat"],
}
}Scope propagation
$invoker propagates to all sub-actions in the ActionEngine via prototype chain scope. If your handler invokes a child action, the child's frame.scope.$invoker inherits from the parent.
Use case: audit trail
const auditHandler = {
analyze: () => ({ capabilities: [], dependencies: [] }),
execute: (_d, frame) => {
const invoker = frame.scope.$invoker;
auditLog.push({
ruleId: invoker.ruleId,
priority: invoker.priority,
timestamp: Date.now(),
});
return { ok: true };
},
};Full API Reference
IRuleEngine<TCtx>
interface IRuleEngine<TCtx> {
// Registration
register(rules: readonly RuleDefinition<TCtx>[]): RuleRegisterResult;
unregister(id: string): boolean;
// Trigger evaluation
evaluate(ctx: TCtx): RuleEvaluationResult;
evaluateAsync(ctx: TCtx): Promise<RuleEvaluationResult>;
// Batch
beginBatch(): void;
endBatch(): RuleRegisterResult;
batch(fn: (engine: IRuleEngine<TCtx>) => void): RuleRegisterResult;
// JIT
compile(): void;
// Introspection
has(id: string): boolean; // accepts qualified IDs for sub-rules
readonly size: number; // top-level rules count
// Accessors
readonly actionEngine: IActionEngine<TCtx>;
readonly isAsync: boolean;
readonly compilationMode: "interpret" | "jit";
}RuleEngineConfig<TCtx>
interface RuleEngineConfig<TCtx> {
actionEngine: IActionEngine<TCtx>; // required
middleware?: readonly RuleMiddleware<TCtx>[]; // optional, default []
ruleHooks?: RuleHooks<TCtx>; // optional
maxSubRuleDepth?: number; // optional, default 10
mode?: "interpret" | "jit" | "auto"; // optional, default "auto"
autoJitThreshold?: number; // optional, default 8
}FrameCounters
interface FrameCounters {
rulesEvaluated: number;
rulesMatched: number;
rulesSkipped: number;
directivesApplied: number;
directivesSkipped: number;
subRunsCreated: number;
errors: number;
}Use Case: RPG Combat Tick
A game tick that evaluates combat rules with sub-rules and halt.
interface GameCtx {
hp: number;
maxHp: number;
mp: number;
zone: string;
effects: string[];
log: string[];
}
// --- ActionEngine setup ---
const actionEngine = createActionEngine<GameCtx>({
handlers: {
dispatch: dispatchHandler,
emit: emitHandler,
log: logHandler,
halt: HALT_HANDLER,
},
});
// --- RuleEngine setup ---
const ruleEngine = createRuleEngine<GameCtx>({
actionEngine,
middleware: [createInvokerMiddleware()],
ruleHooks: {
beforeRule: (rule) => {
if (rule.priority < 50) return "skip";
},
},
});
// --- Rules ---
ruleEngine.register([
// High-priority death check — halts everything
{
id: "death-check",
priority: 500,
when: (ctx) => ctx.hp <= 0,
then: [
{ type: "log", message: "dead — halting" },
{ type: "halt" },
],
},
// Combat zone rules with sub-rules
{
id: "combat-zone",
priority: 100,
when: (ctx) => ctx.zone === "combat",
then: [
{ type: "dispatch", target: "hp", op: "dec", value: 15 },
{ type: "emit", event: "damage-tick" },
],
rules: [
{
id: "low-hp-heal",
when: (ctx) => ctx.hp < 50,
then: [
{ type: "dispatch", target: "hp", op: "inc", value: 5 },
],
},
],
},
// MP regen
{
id: "mp-regen",
priority: 50,
when: (ctx) => ctx.mp < 100,
then: [
{ type: "dispatch", target: "mp", op: "inc", value: 3 },
],
},
]);
// --- Tick ---
const ctx: GameCtx = { hp: 40, maxHp: 100, mp: 30, zone: "combat", effects: [], log: [] };
const result = ruleEngine.evaluate(ctx);
// combat-zone fires: hp 40->25, emits "damage-tick"
// low-hp-heal fires: hp 25->30
// mp-regen fires: mp 30->33Use Case: E-commerce Order Pipeline
An order processing pipeline with validation, tiered discounts, and fraud detection.
interface OrderCtx {
orderId: string;
status: string;
items: Array<{ sku: string; qty: number; price: number }>;
subtotal: number;
discount: number;
total: number;
customerTier: string;
coupon: string | null;
paymentMethod: string;
flags: string[];
log: string[];
}
ruleEngine.register([
// 1. Fraud detection — highest priority, halts pipeline
{
id: "fraud-check",
priority: 500,
when: (ctx) => ctx.subtotal > 1000 && ctx.customerTier === "bronze",
then: [
{ type: "dispatch", target: "flags", op: "push", value: "fraud-review" },
{ type: "dispatch", target: "status", op: "set", value: "held" },
{ type: "halt" },
],
},
// 2. Calculate subtotal
{
id: "calc-subtotal",
priority: 400,
when: (ctx) => ctx.status === "pending",
then: [
{
type: "dispatch", target: "subtotal", op: "set", value: 0,
resolve: (ctx) => ({
value: ctx.items.reduce((sum, i) => sum + i.qty * i.price, 0),
}),
},
{ type: "dispatch", target: "status", op: "set", value: "validated" },
],
},
// 3. Tiered discounts via sub-rules
{
id: "discount-engine",
priority: 300,
when: (ctx) => ctx.status === "validated" && ctx.discount === 0,
then: [{ type: "log", message: "evaluating discounts" }],
rules: [
{
id: "gold-discount",
when: (ctx) => ctx.customerTier === "gold",
then: [
{ type: "dispatch", target: "discount", op: "set", value: 0,
resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.15) }) },
],
},
{
id: "silver-discount",
when: (ctx) => ctx.customerTier === "silver",
then: [
{ type: "dispatch", target: "discount", op: "set", value: 0,
resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.1) }) },
],
},
],
},
// 4. Payment
{
id: "process-payment",
priority: 200,
when: (ctx) => ctx.status === "validated",
then: [
{ type: "dispatch", target: "total", op: "set", value: 0,
resolve: (ctx) => ({ value: ctx.subtotal - ctx.discount }) },
{ type: "dispatch", target: "status", op: "set", value: "paid" },
{ type: "emit", event: "payment-processed" },
],
},
]);Use Case: Deployment Approval Workflow
A CI/CD pipeline with submission, reviewer assignment, approval gates, and deploy strategy selection via sub-rules.
interface WorkflowCtx {
id: string;
status: string;
type: string;
environment: string;
author: string;
reviewers: string[];
approvals: string[];
rejections: string[];
requiredApprovals: number;
riskScore: number;
flags: string[];
metadata: Record<string, unknown>;
log: string[];
}
ruleEngine.register([
// 1. Rejection check — highest priority, halts
{
id: "check-rejections",
priority: 500,
when: (ctx) => ctx.status === "in-review" && ctx.rejections.length > 0,
then: [
{ type: "dispatch", target: "status", op: "set", value: "rejected" },
{ type: "halt" },
],
},
// 2. Submission with reviewer assignment via sub-rules
{
id: "submit",
priority: 400,
when: (ctx) => ctx.status === "draft",
then: [
{ type: "dispatch", target: "status", op: "set", value: "submitted" },
],
rules: [
{
id: "assign-feature-reviewers",
when: (ctx) => ctx.type === "feature",
then: [
{ type: "dispatch", target: "reviewers", op: "push", value: "tech-lead" },
{ type: "dispatch", target: "reviewers", op: "push", value: "senior-dev" },
],
},
{
id: "assign-infra-reviewers",
when: (ctx) => ctx.type === "infra",
then: [
{ type: "dispatch", target: "reviewers", op: "push", value: "devops-lead" },
{ type: "dispatch", target: "reviewers", op: "push", value: "sre" },
],
},
],
},
// 3. Deploy strategy by environment + risk via sub-rules
{
id: "deploy",
priority: 100,
when: (ctx) => ctx.status === "approved",
then: [{ type: "dispatch", target: "status", op: "set", value: "deploying" }],
rules: [
{
id: "deploy-production",
when: (ctx) => ctx.environment === "production",
then: [{ type: "log", message: "production deploy" }],
rules: [
{
id: "canary-deploy",
when: (ctx) => ctx.riskScore > 50,
then: [
{ type: "dispatch", target: "flags", op: "push", value: "canary" },
],
},
{
id: "blue-green-deploy",
when: (ctx) => ctx.riskScore <= 50,
then: [
{ type: "dispatch", target: "flags", op: "push", value: "blue-green" },
],
},
],
},
],
},
]);Performance Spectrum
Tier 0 (no hooks, no middleware) Full (hooks + middleware + sub-rules)
---------------------------------------------------------------------
Zero allocations for hooks evalCtx created per evaluate()
No try/catch try/catch per hook
No params variable params allocated + middleware pipeline
Minimal loop Full governance pipelineThe JIT compiler emits only the code paths that are configured. Tier 0 is the common case for high-performance scenarios (game loops, real-time).
Internal Design Notes
For detailed internal architecture, see docs/ARCHITECTURE.md.
Key decisions
- Rules are hidden actions with prefix
rule:. Sub-rules use.separator. - Sub-rules are always interpreted — JIT compiles only the main loop.
- Single exit point in the interpreter —
onRulesCompletecalled exactly once. - Closure counter + noop swap for auto-promote — zero overhead post-JIT.
Object.assignin-place for middleware — 1 allocation vs N+1.- Immutability by convention — no
Object.freeze()(measurable cost in hot paths). - Events are a separate package —
@statedelta-actions/events. The RuleEngine does not know about events.
