npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

  1. Philosophy
  2. Architecture Overview
  3. Installation & Setup
  4. Quick Start
  5. Rule Definition
  6. Registration
  7. Trigger Evaluation
  8. Sub-rules (Conditional Cascade)
  9. Hooks (Governance)
  10. Middleware (Params Enrichment)
  11. JIT Compilation
  12. Batch Operations
  13. Async Support
  14. Error Handling
  15. HALT_HANDLER
  16. createInvokerMiddleware
  17. Full API Reference
  18. Use Case: RPG Combat Tick
  19. Use Case: E-commerce Order Pipeline
  20. Use Case: Deployment Approval Workflow
  21. Performance Spectrum
  22. 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 -> repeat

Composition 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/Async

Public 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

  1. Validate each rule (id, priority, when, then/rules)
  2. Normalize to hidden action: rule:{id}
  3. Collect sub-rules recursively with dot-separated IDs
  4. Index in internal registries (_rules sorted by priority desc)
  5. Register hidden actions in ActionEngine
  6. 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 removed

Removes 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 result

RuleEvaluationResult

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 === 2

Sub-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-rule

Group 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 up

Group 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 -> next

No 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 compiled

compile()

engine.compile(); // forces immediate promotion to JIT

Useful 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 throw

batch(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 === true
  • result.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->33

Use 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 pipeline

The 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 — onRulesComplete called exactly once.
  • Closure counter + noop swap for auto-promote — zero overhead post-JIT.
  • Object.assign in-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.