npm package: @statedelta-actions/rules
Description: Rule evaluation engine with JIT-optimized sequential evaluation and sub-rules
Author: Anderson D. Rosa
Latest version: 0.3.0

# @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](#philosophy)
2. [Architecture Overview](#architecture-overview)
3. [Installation & Setup](#installation--setup)
4. [Quick Start](#quick-start)
5. [Rule Definition](#rule-definition)
6. [Registration](#registration)
7. [Trigger Evaluation](#trigger-evaluation)
8. [Sub-rules (Conditional Cascade)](#sub-rules-conditional-cascade)
9. [Hooks (Governance)](#hooks-governance)
10. [Middleware (Params Enrichment)](#middleware-params-enrichment)
11. [JIT Compilation](#jit-compilation)
12. [Batch Operations](#batch-operations)
13. [Async Support](#async-support)
14. [Error Handling](#error-handling)
15. [HALT_HANDLER](#halt_handler)
16. [createInvokerMiddleware](#createinvokermiddleware)
17. [Full API Reference](#full-api-reference)
18. [Use Case: RPG Combat Tick](#use-case-rpg-combat-tick)
19. [Use Case: E-commerce Order Pipeline](#use-case-e-commerce-order-pipeline)
20. [Use Case: Deployment Approval Workflow](#use-case-deployment-approval-workflow)
21. [Performance Spectrum](#performance-spectrum)
22. [Internal Design Notes](#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

```typescript
// 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

```typescript
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

```typescript
// 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**.

```typescript
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`.

```typescript
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

```typescript
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

```typescript
interface RuleRegisterResult {
  readonly registered: readonly string[];
  readonly errors: readonly RuleRegisterError[];
  readonly warnings: readonly RegisterWarning[];
}
```

### Unregister

```typescript
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

```typescript
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

```typescript
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

```typescript
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:

```typescript
{
  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

```typescript
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

```typescript
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 |

```typescript
interface RuleEvaluationContext<TCtx> {
  readonly ctx: TCtx;
  readonly counters: FrameCounters;
}
```

### Example: governance by priority

```typescript
const ruleEngine = createRuleEngine({
  actionEngine,
  ruleHooks: {
    beforeRule: (rule) => {
      if (rule.priority < 50) return "skip";
    },
  },
});
```

### Example: abort on error

```typescript
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

```typescript
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

```typescript
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

```typescript
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()

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

Useful for warmup. No-op if `mode === "interpret"`.

### compilationMode getter

```typescript
engine.compilationMode // "interpret" | "jit"
```

Reflects current state — switches from `"interpret"` to `"jit"` after promotion.

---

## Batch Operations

### Callback-based (recommended)

```typescript
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

```typescript
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

```typescript
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

```typescript
// 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

```typescript
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

```typescript
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.

```typescript
import { HALT_HANDLER } from "@statedelta-actions/rules";

const actionEngine = createActionEngine({
  handlers: {
    ...myHandlers,
    halt: HALT_HANDLER,
  },
});
```

Usage in directives:

```typescript
{
  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:

```typescript
import { createInvokerMiddleware } from "@statedelta-actions/rules";

const ruleEngine = createRuleEngine({
  actionEngine,
  middleware: [createInvokerMiddleware()],
});
```

### What it injects

```typescript
{
  $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

```typescript
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\>

```typescript
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\>

```typescript
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

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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`](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.