@statedelta-actions/rules 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1218 @@
1
+ # @statedelta-actions/rules
2
+
3
+ > Rule Engine — superset layer on top of ActionEngine.
4
+ > Conditional trigger execution by priority.
5
+ >
6
+ > Rule = trigger. Events are a separate package (`@statedelta-actions/events`).
7
+
8
+ ---
9
+
10
+ ## Table of Contents
11
+
12
+ 1. [Philosophy](#philosophy)
13
+ 2. [Architecture Overview](#architecture-overview)
14
+ 3. [Installation & Setup](#installation--setup)
15
+ 4. [Quick Start](#quick-start)
16
+ 5. [Rule Definition](#rule-definition)
17
+ 6. [Registration](#registration)
18
+ 7. [Trigger Evaluation](#trigger-evaluation)
19
+ 8. [Sub-rules (Conditional Cascade)](#sub-rules-conditional-cascade)
20
+ 9. [Hooks (Governance)](#hooks-governance)
21
+ 10. [Middleware (Params Enrichment)](#middleware-params-enrichment)
22
+ 11. [JIT Compilation](#jit-compilation)
23
+ 12. [Batch Operations](#batch-operations)
24
+ 13. [Async Support](#async-support)
25
+ 14. [Error Handling](#error-handling)
26
+ 15. [HALT_HANDLER](#halt_handler)
27
+ 16. [createInvokerMiddleware](#createinvokermiddleware)
28
+ 17. [Full API Reference](#full-api-reference)
29
+ 18. [Use Case: RPG Combat Tick](#use-case-rpg-combat-tick)
30
+ 19. [Use Case: E-commerce Order Pipeline](#use-case-e-commerce-order-pipeline)
31
+ 20. [Use Case: Deployment Approval Workflow](#use-case-deployment-approval-workflow)
32
+ 21. [Performance Spectrum](#performance-spectrum)
33
+ 22. [Internal Design Notes](#internal-design-notes)
34
+
35
+
36
+ ---
37
+
38
+ ## Philosophy
39
+
40
+ ### Rule = Trigger
41
+
42
+ 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.
43
+
44
+ ```
45
+ Registration:
46
+ rule { id: "combat-heal", when: ..., then: [...] }
47
+ -> actionEngine.register([{ id: "rule:combat-heal", directives: [...] }])
48
+
49
+ Evaluation:
50
+ when(ctx) === true
51
+ -> actionEngine.invoke("rule:combat-heal", params)
52
+ ```
53
+
54
+ After registration, the RuleEngine is a **loop of `when() -> invoke()`**.
55
+
56
+ ### RuleEngine receives, does not create
57
+
58
+ 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:
59
+
60
+ - Registering handlers (`dispatch`, `emit`, `halt`, custom)
61
+ - Setting limits, JIT mode
62
+ - Configuring the ActionAnalyzer separately if static analysis is needed
63
+
64
+ This separation means the same ActionEngine can serve both direct action invocations and rule-driven invocations.
65
+
66
+ ### Hooks = governance, handlers = flow control
67
+
68
+ 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.
69
+
70
+ ### Middleware = params enrichment, not ctx transformation
71
+
72
+ 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.
73
+
74
+ ### Never throws during evaluation
75
+
76
+ 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`.
77
+
78
+ ### Feature not used = zero overhead
79
+
80
+ 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.
81
+
82
+ ---
83
+
84
+ ## Architecture Overview
85
+
86
+ ### Monorepo Position
87
+
88
+ ```
89
+ @statedelta-actions/core <- shared types, slots, frame
90
+ |
91
+ @statedelta-actions/actions <- ActionEngine — runtime puro (directives, handlers, JIT)
92
+ |
93
+ @statedelta-actions/rules <- RuleEngine (this package)
94
+ @statedelta-actions/events <- EventProcessor (separate package)
95
+
96
+ @statedelta-actions/graph <- dependency graph (consumed by analyzer, not by actions/rules)
97
+ @statedelta-actions/analyzer <- ActionAnalyzer — static analysis, capabilities (opt-in)
98
+
99
+ (future) tick-runner / realm <- PPP: evaluate -> drain events -> repeat
100
+ ```
101
+
102
+ ### Composition Model
103
+
104
+ ```
105
+ Consumer (tick-runner, game loop, business layer)
106
+ |
107
+ +-- configures ActionEngine (handlers, limits)
108
+ +-- creates RuleEngine(actionEngine, middleware, hooks)
109
+ +-- registers rules
110
+ +-- calls evaluate(ctx)
111
+ ```
112
+
113
+ ### Module Structure
114
+
115
+ ```
116
+ src/
117
+ +-- types.ts <- All types and interfaces + RuleEvaluatorFn
118
+ +-- engine.ts <- RuleEngineImpl + createRuleEngine + registry helpers (~470 lines)
119
+ +-- validate.ts <- validateRule, validateSubRule
120
+ +-- handlers.ts <- HALT_HANDLER
121
+ +-- middleware.ts <- createInvokerMiddleware (public) + runMiddleware (internal)
122
+ +-- index.ts <- Public re-exports
123
+ +-- eval/ <- Evaluation runtime
124
+ | +-- interpreter.ts <- Rule evaluation interpreter sync/async + result builders
125
+ | +-- jit.ts <- buildRuleExecutor + emitReturn codegen
126
+ | +-- sub-rules.ts <- evaluateSubRulesSync/Async
127
+ ```
128
+
129
+ ### Public Exports
130
+
131
+ ```typescript
132
+ // Factory
133
+ export { createRuleEngine } from "./engine";
134
+
135
+ // Handlers
136
+ export { HALT_HANDLER } from "./handlers";
137
+
138
+ // Middleware
139
+ export { createInvokerMiddleware } from "./middleware";
140
+
141
+ // Types
142
+ export type {
143
+ SubRuleDefinition,
144
+ RuleDefinition,
145
+ RuleRegisterResult,
146
+ RuleRegisterError,
147
+ RuleEvaluationResult,
148
+ RuleEvaluationContext,
149
+ RuleEngineConfig,
150
+ IRuleEngine,
151
+ RuleHooks,
152
+ RuleMiddleware,
153
+ } from "./types";
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Installation & Setup
159
+
160
+ ```typescript
161
+ import { createActionEngine } from "@statedelta-actions/actions";
162
+ import {
163
+ createRuleEngine,
164
+ HALT_HANDLER,
165
+ createInvokerMiddleware,
166
+ } from "@statedelta-actions/rules";
167
+
168
+ // 1. Configure ActionEngine with your handlers
169
+ const actionEngine = createActionEngine<MyCtx>({
170
+ handlers: {
171
+ dispatch: myDispatchHandler,
172
+ emit: myEmitHandler,
173
+ halt: HALT_HANDLER,
174
+ },
175
+ });
176
+
177
+ // 2. Create RuleEngine, passing the ActionEngine
178
+ const ruleEngine = createRuleEngine<MyCtx>({
179
+ actionEngine,
180
+ middleware: [createInvokerMiddleware()], // optional
181
+ ruleHooks: { // optional
182
+ beforeRule: (rule, evalCtx) => { /* guard */ },
183
+ afterRule: (rule, result, evalCtx) => { /* observe */ },
184
+ onRulesComplete: (result) => { /* cleanup */ },
185
+ },
186
+ maxSubRuleDepth: 10, // default
187
+ mode: "auto", // "interpret" | "jit" | "auto" (default)
188
+ autoJitThreshold: 8, // default
189
+ });
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Quick Start
195
+
196
+ ```typescript
197
+ // Register rules
198
+ ruleEngine.register([
199
+ {
200
+ id: "heal-when-low",
201
+ priority: 100,
202
+ when: (ctx) => ctx.hp < 50,
203
+ then: [
204
+ { type: "dispatch", target: "hp", op: "inc", value: 20 },
205
+ ],
206
+ },
207
+ {
208
+ id: "regen-mp",
209
+ priority: 50,
210
+ when: (ctx) => ctx.mp < 100,
211
+ then: [
212
+ { type: "dispatch", target: "mp", op: "inc", value: 5 },
213
+ ],
214
+ },
215
+ ]);
216
+
217
+ // Evaluate all trigger rules against current state
218
+ const result = ruleEngine.evaluate(ctx);
219
+
220
+ // result.success -> true if completed without abort
221
+ // result.matched -> ["heal-when-low", "regen-mp"] rule IDs
222
+ // result.counters -> { rulesEvaluated: 2, rulesMatched: 2, ... }
223
+ ```
224
+
225
+ ---
226
+
227
+ ## Rule Definition
228
+
229
+ ### SubRuleDefinition
230
+
231
+ Base type for sub-rules. No `priority` — sub-rules execute in **declaration order**.
232
+
233
+ ```typescript
234
+ interface SubRuleDefinition<TCtx> {
235
+ readonly id: string; // unique identifier
236
+ readonly when?: (ctx: TCtx) => boolean; // trigger condition
237
+ readonly then?: readonly Directive<TCtx>[]; // action directives
238
+ readonly rules?: readonly SubRuleDefinition<TCtx>[]; // nested sub-rules
239
+ readonly tags?: readonly string[];
240
+ readonly effects?: readonly string[];
241
+ readonly declarations?: Record<string, unknown>;
242
+ readonly metadata?: Record<string, unknown>;
243
+ }
244
+ ```
245
+
246
+ ### RuleDefinition
247
+
248
+ Top-level rules extend `SubRuleDefinition` with mandatory `priority`.
249
+
250
+ ```typescript
251
+ interface RuleDefinition<TCtx> extends SubRuleDefinition<TCtx> {
252
+ readonly priority: number; // higher = first (desc order)
253
+ readonly rules?: readonly SubRuleDefinition<TCtx>[]; // sub-rules (no priority)
254
+ }
255
+ ```
256
+
257
+ A rule **must** have `when` (trigger condition).
258
+
259
+ 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.
260
+
261
+ ### Priority
262
+
263
+ 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`.
264
+
265
+ ### Validation
266
+
267
+ | Check | Error Code |
268
+ |-------|-----------|
269
+ | `id` must be a non-empty string | `INVALID_RULE` |
270
+ | `priority` must be a number | `INVALID_RULE` |
271
+ | Must have `when` function | `INVALID_RULE` |
272
+ | Must have `then` or `rules` (or both) | `INVALID_RULE` |
273
+ | Duplicate ID | `DUPLICATE_ID` |
274
+
275
+ Sub-rule validation:
276
+
277
+ | Check | Error Code |
278
+ |-------|-----------|
279
+ | `id` must be a non-empty string | `INVALID_RULE` |
280
+ | Must have `then` or `rules` (or both) | `INVALID_RULE` |
281
+
282
+ ---
283
+
284
+ ## Registration
285
+
286
+ ```typescript
287
+ const result = ruleEngine.register([rule1, rule2, ...]);
288
+ ```
289
+
290
+ ### Registration pipeline
291
+
292
+ 1. **Validate** each rule (id, priority, when, then/rules)
293
+ 2. **Normalize** to hidden action: `rule:{id}`
294
+ 3. **Collect sub-rules** recursively with dot-separated IDs
295
+ 4. **Index** in internal registries (`_rules` sorted by priority desc)
296
+ 5. **Register** hidden actions in ActionEngine
297
+ 6. **Return** merged result
298
+
299
+ ### RuleRegisterResult
300
+
301
+ ```typescript
302
+ interface RuleRegisterResult {
303
+ readonly registered: readonly string[];
304
+ readonly errors: readonly RuleRegisterError[];
305
+ readonly warnings: readonly RegisterWarning[];
306
+ }
307
+ ```
308
+
309
+ ### Unregister
310
+
311
+ ```typescript
312
+ const removed = ruleEngine.unregister("rule-id"); // true if found and removed
313
+ ```
314
+
315
+ Removes the rule from all internal registries, unregisters the hidden action from ActionEngine, and recursively cleans up sub-rules.
316
+
317
+ ---
318
+
319
+ ## Trigger Evaluation
320
+
321
+ ```typescript
322
+ const result = ruleEngine.evaluate(ctx);
323
+ ```
324
+
325
+ Processes all rules in priority descending order:
326
+
327
+ ```
328
+ evaluate(ctx)
329
+ |
330
+ actionEngine.setContext(ctx)
331
+ |
332
+ for each rule (priority desc):
333
+ |
334
+ +-- beforeRule hook -> "skip" -> skipped | "abort" -> return | void -> continue
335
+ +-- when(ctx) -> false -> notMatched | error -> collect, notMatched
336
+ +-- matched
337
+ +-- Middleware pipeline -> params (or error -> collect, skip invoke)
338
+ +-- Invoke action (if has then) -> DirectiveResult
339
+ +-- afterRule hook -> "abort" -> return | void -> continue
340
+ +-- Halt check -> aborted -> return
341
+ +-- Sub-rules cascade -> aborted -> return
342
+ |
343
+ onRulesComplete(result)
344
+ return result
345
+ ```
346
+
347
+ ### RuleEvaluationResult
348
+
349
+ ```typescript
350
+ interface RuleEvaluationResult {
351
+ readonly success: boolean; // true if completed without abort
352
+ readonly aborted: boolean; // true if stopped early
353
+ readonly abortedBy?: string; // "beforeRule" | "afterRule" | "sub-rule" | "halt" | handler
354
+ readonly matched: readonly string[]; // IDs of matched rules
355
+ readonly skipped: readonly string[]; // IDs skipped by beforeRule
356
+ readonly notMatched: readonly string[];
357
+ readonly errors: readonly RuleError[];
358
+ readonly processedCount: number; // rules processed (< totalCount on abort)
359
+ readonly totalCount: number;
360
+ readonly counters: FrameCounters; // accumulated across all nesting
361
+ }
362
+ ```
363
+
364
+ ### Example
365
+
366
+ ```typescript
367
+ const ruleEngine = createRuleEngine({ actionEngine });
368
+
369
+ ruleEngine.register([
370
+ {
371
+ id: "shield",
372
+ priority: 200,
373
+ when: (ctx) => ctx.inCombat,
374
+ then: [{ type: "dispatch", target: "defense", op: "inc", value: 10 }],
375
+ },
376
+ {
377
+ id: "heal",
378
+ priority: 100,
379
+ when: (ctx) => ctx.hp < 50,
380
+ then: [{ type: "dispatch", target: "hp", op: "inc", value: 20 }],
381
+ },
382
+ ]);
383
+
384
+ const ctx = { inCombat: true, hp: 30, defense: 0 };
385
+ const result = ruleEngine.evaluate(ctx);
386
+
387
+ // shield (200) executes first, then heal (100)
388
+ // ctx.defense === 10, ctx.hp === 50
389
+ // result.matched === ["shield", "heal"]
390
+ // result.counters.rulesMatched === 2
391
+ ```
392
+
393
+ ---
394
+
395
+ ## Sub-rules (Conditional Cascade)
396
+
397
+ 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).
398
+
399
+ ### Registration
400
+
401
+ Sub-rules are registered recursively as hidden actions with dot-separated IDs:
402
+
403
+ ```
404
+ rule:combat-heal <- parent
405
+ rule:combat-heal.low-hp <- sub-rule
406
+ rule:combat-heal.low-hp.crit <- nested sub-rule
407
+ ```
408
+
409
+ Group gates (no `then`) register no action — `actionId = ""`.
410
+
411
+ ### Evaluation
412
+
413
+ ```
414
+ After parent invoke (or directly for group gate):
415
+ for each sub-rule (declaration order):
416
+ +-- when(ctx) -> false -> skip | undefined -> unconditional
417
+ +-- Invoke (if has then) -> aborted -> propagate up
418
+ +-- Recurse (if has sub-rules) -> aborted -> propagate up
419
+ ```
420
+
421
+ ### Group gates
422
+
423
+ A group gate is a rule with `rules` but no `then`. It acts as a pure conditional container:
424
+
425
+ ```typescript
426
+ {
427
+ id: "combat-group",
428
+ priority: 100,
429
+ when: (ctx) => ctx.zone === "combat",
430
+ // no `then` — this is a gate
431
+ rules: [
432
+ { id: "heal", when: (ctx) => ctx.hp < 50, then: [...] },
433
+ { id: "buff", when: (ctx) => ctx.mp > 0, then: [...] },
434
+ ],
435
+ }
436
+ ```
437
+
438
+ The gate evaluates `when()`. If true, children are evaluated. No action is invoked for the gate itself.
439
+
440
+ ### Design decisions
441
+
442
+ | Decision | Rationale |
443
+ |----------|-----------|
444
+ | Declaration order, not priority | Sub-rules are a cascade within one rule — order matters semantically |
445
+ | Hooks do NOT apply to sub-rules | Hooks govern the main loop, not internal branching |
446
+ | Middleware params inherited | Parent's middleware-enriched `params` propagate as-is |
447
+ | Abort propagates upward | Sub-rule halt -> parent aborts -> main loop stops |
448
+ | Depth limited | Default 10. Exceeding -> error collected, sub-tree skipped (no abort) |
449
+
450
+ ### Example: tiered discounts
451
+
452
+ ```typescript
453
+ ruleEngine.register([
454
+ {
455
+ id: "discount-engine",
456
+ priority: 200,
457
+ when: (ctx) => ctx.status === "validated" && ctx.discount === 0,
458
+ then: [{ type: "log", message: "evaluating discounts" }],
459
+ rules: [
460
+ {
461
+ id: "vip-discount",
462
+ when: (ctx) => ctx.customerTier === "vip",
463
+ then: [
464
+ { type: "dispatch", target: "discount", op: "set", value: 0,
465
+ resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.2) }) },
466
+ ],
467
+ },
468
+ {
469
+ id: "gold-discount",
470
+ when: (ctx) => ctx.customerTier === "gold",
471
+ then: [
472
+ { type: "dispatch", target: "discount", op: "set", value: 0,
473
+ resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.15) }) },
474
+ ],
475
+ },
476
+ ],
477
+ },
478
+ ]);
479
+ ```
480
+
481
+ ---
482
+
483
+ ## Hooks (Governance)
484
+
485
+ Hooks are analyzed once at construction time via `analyzeSlots`. They govern rule evaluation only. Events have their own hooks in `@statedelta-actions/events`.
486
+
487
+ ### Rule Hooks
488
+
489
+ ```typescript
490
+ ruleHooks: {
491
+ beforeRule?: (rule: RuleDefinition, evalCtx: RuleEvaluationContext) => "skip" | "abort" | void;
492
+ afterRule?: (rule: RuleDefinition, result: DirectiveResult, evalCtx: RuleEvaluationContext) => "abort" | void;
493
+ onRulesComplete?: (result: RuleEvaluationResult) => void;
494
+ }
495
+ ```
496
+
497
+ | Hook | When | Returns | Error behavior |
498
+ |------|------|---------|----------------|
499
+ | `beforeRule` | Before `when()` evaluation | `"skip"` -> skipped, `"abort"` -> stop pipeline, `void` -> continue | Collected in `errors[]`, continue |
500
+ | `afterRule` | After invoke (if rule had `then`) | `"abort"` -> stop pipeline, `void` -> continue | Fire-and-forget |
501
+ | `onRulesComplete` | After all rules processed (or abort) | `void` | Silenced |
502
+
503
+ ```typescript
504
+ interface RuleEvaluationContext<TCtx> {
505
+ readonly ctx: TCtx;
506
+ readonly counters: FrameCounters;
507
+ }
508
+ ```
509
+
510
+ ### Example: governance by priority
511
+
512
+ ```typescript
513
+ const ruleEngine = createRuleEngine({
514
+ actionEngine,
515
+ ruleHooks: {
516
+ beforeRule: (rule) => {
517
+ if (rule.priority < 50) return "skip";
518
+ },
519
+ },
520
+ });
521
+ ```
522
+
523
+ ### Example: abort on error
524
+
525
+ ```typescript
526
+ ruleHooks: {
527
+ afterRule: (rule, result) => {
528
+ if (result.errors.length > 0) return "abort";
529
+ },
530
+ },
531
+ ```
532
+
533
+ ---
534
+
535
+ ## Middleware (Params Enrichment)
536
+
537
+ Middleware runs between match and invoke. It enriches the `params` that become the action's scope.
538
+
539
+ ### Composition model
540
+
541
+ Delta-based. Each middleware receives the accumulated params and returns a delta to merge:
542
+
543
+ ```
544
+ params0 = {} -> mw[0](rule, ctx, params0) -> d0 -> params1 = {...params0, ...d0}
545
+ mw[1](rule, ctx, params1) -> d1 -> params2 = {...params1, ...d1}
546
+ ...
547
+ invoke(actionId, paramsN)
548
+ ```
549
+
550
+ Middleware cannot drop what predecessors injected (only overwrite by key).
551
+
552
+ ### Signature
553
+
554
+ ```typescript
555
+ type RuleMiddleware<TCtx> = (
556
+ rule: RuleDefinition<TCtx>,
557
+ ctx: TCtx,
558
+ params: Record<string, unknown>,
559
+ ) => Record<string, unknown>;
560
+ ```
561
+
562
+ ### Sub-rule middleware
563
+
564
+ Middleware does **not** re-run for sub-rules. The parent's middleware-enriched `params` are passed directly to sub-rule invocations.
565
+
566
+ ### Error handling
567
+
568
+ Middleware error -> collected in `errors[]`, invoke skipped, next rule continues.
569
+
570
+ ### Example: custom audit middleware
571
+
572
+ ```typescript
573
+ const auditMiddleware: RuleMiddleware<MyCtx> = (rule, ctx, params) => ({
574
+ $audit: {
575
+ ruleId: rule.id,
576
+ timestamp: Date.now(),
577
+ userId: ctx.currentUser.id,
578
+ },
579
+ });
580
+
581
+ const ruleEngine = createRuleEngine({
582
+ actionEngine,
583
+ middleware: [auditMiddleware, createInvokerMiddleware()],
584
+ });
585
+ ```
586
+
587
+ ---
588
+
589
+ ## JIT Compilation
590
+
591
+ 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.
592
+
593
+ ### What is compiled
594
+
595
+ `buildRuleExecutor` generates a function via `new Function` that replaces the interpreter. Same signature — the engine swaps transparently.
596
+
597
+ ### Conditional code emission
598
+
599
+ The generated JS only contains code for features that are actually configured:
600
+
601
+ | Feature absent | Code not emitted |
602
+ |---|---|
603
+ | `beforeRule` | Entire try/catch block, skip/abort checks |
604
+ | `afterRule` | Entire try/catch block, abort check |
605
+ | `onRulesComplete` | Final call + cleanup blocks |
606
+ | No hooks at all | `evalCtx` not created, no hook variables |
607
+ | No middleware | `params` variable not declared, no pipeline call |
608
+
609
+ ### Tier 0 — tight loop
610
+
611
+ With zero hooks and zero middleware, the generated code is a minimal loop:
612
+
613
+ ```
614
+ for -> when(ctx) -> invoke(actionId) -> halt check -> sub-rules -> next
615
+ ```
616
+
617
+ No try/catch for hooks. No middleware pipeline. No evalCtx allocation.
618
+
619
+ ### Modes
620
+
621
+ ```typescript
622
+ const engine = createRuleEngine({ actionEngine, mode: "auto" });
623
+ ```
624
+
625
+ | Mode | Behavior |
626
+ |------|----------|
627
+ | `interpret` | Always use interpreter. JIT never activates. `compile()` is a no-op. |
628
+ | `jit` | Compile immediately at construction. No interpreter phase. |
629
+ | `auto` (default) | Start with interpreter. Promote after threshold calls. |
630
+
631
+ Default threshold: 8 `evaluate()` calls. Configurable via `autoJitThreshold`.
632
+
633
+ ```
634
+ evaluate() #1..#7 -> interpreter
635
+ evaluate() #8 -> compile + swap interpreter for JIT
636
+ evaluate() #9+ -> JIT compiled
637
+ ```
638
+
639
+ ### compile()
640
+
641
+ ```typescript
642
+ engine.compile(); // forces immediate promotion to JIT
643
+ ```
644
+
645
+ Useful for warmup. No-op if `mode === "interpret"`.
646
+
647
+ ### compilationMode getter
648
+
649
+ ```typescript
650
+ engine.compilationMode // "interpret" | "jit"
651
+ ```
652
+
653
+ Reflects current state — switches from `"interpret"` to `"jit"` after promotion.
654
+
655
+ ---
656
+
657
+ ## Batch Operations
658
+
659
+ ### Callback-based (recommended)
660
+
661
+ ```typescript
662
+ const result = engine.batch((eng) => {
663
+ eng.register([rule1, rule2]);
664
+ eng.register([rule3, rule4]);
665
+ });
666
+ // endBatch() called automatically, even on throw
667
+ ```
668
+
669
+ `batch(fn)` guarantees cleanup: if `fn` throws, `endBatch()` is still called to avoid leaving the engine in an inconsistent state.
670
+
671
+ ### Manual
672
+
673
+ ```typescript
674
+ engine.beginBatch();
675
+ engine.register([rule1, rule2]);
676
+ engine.register([rule3, rule4]);
677
+ const result = engine.endBatch();
678
+ ```
679
+
680
+ - Delegates `beginBatch()`/`endBatch()` to ActionEngine
681
+ - Accumulates registration results across multiple `register()` calls
682
+ - Merges everything on `endBatch()`
683
+ - Supports nesting: inner `endBatch()` returns empty, outer merges all
684
+
685
+ ---
686
+
687
+ ## Async Support
688
+
689
+ ### Detection
690
+
691
+ ```typescript
692
+ const isAsync = ruleHookAnalysis.hasAnyAsync || actionEngine.isAsync;
693
+ ```
694
+
695
+ Computed once at construction. Immutable. If any rule hook is async, or if the ActionEngine has async directive hooks, `isAsync === true`.
696
+
697
+ ### Usage
698
+
699
+ ```typescript
700
+ // Sync (throws if isAsync)
701
+ const result = engine.evaluate(ctx);
702
+
703
+ // Async (always works)
704
+ const result = await engine.evaluateAsync(ctx);
705
+ ```
706
+
707
+ Calling `evaluate()` when `isAsync === true` throws an error — must use `evaluateAsync()`. This is a guard against accidentally dropping promises.
708
+
709
+ ### Async hooks
710
+
711
+ ```typescript
712
+ const ruleEngine = createRuleEngine({
713
+ actionEngine,
714
+ ruleHooks: {
715
+ beforeRule: async (rule, evalCtx) => {
716
+ const allowed = await checkPermission(rule.id);
717
+ if (!allowed) return "skip";
718
+ },
719
+ afterRule: async (rule, result) => {
720
+ await logToExternalService(rule.id, result);
721
+ },
722
+ },
723
+ });
724
+
725
+ // Must use evaluateAsync
726
+ const result = await ruleEngine.evaluateAsync(ctx);
727
+ ```
728
+
729
+ ---
730
+
731
+ ## Error Handling
732
+
733
+ The engine **never throws** during evaluation. All errors are collected:
734
+
735
+ | Error source | Behavior |
736
+ |-------------|----------|
737
+ | `when()` throws | Error collected, rule treated as `notMatched`, continue |
738
+ | `beforeRule` throws | Error collected, continue (treated as void) |
739
+ | `afterRule` throws | Fire-and-forget, continue |
740
+ | `onRulesComplete` throws | Silenced |
741
+ | Middleware throws | Error collected, invoke skipped, next rule |
742
+ | ActionEngine invoke errors | Directive errors accumulated in counters |
743
+ | Sub-rule `when()` throws | Error collected, sub-rule skipped |
744
+ | `maxSubRuleDepth` exceeded | Error collected, sub-tree skipped (no abort) |
745
+
746
+ ### Inspecting errors
747
+
748
+ ```typescript
749
+ const result = engine.evaluate(ctx);
750
+
751
+ for (const err of result.errors) {
752
+ console.log(`Rule index ${err.ruleIndex}: ${err.error}`);
753
+ }
754
+
755
+ // Consumer decides what errors mean:
756
+ if (result.errors.length > 0) {
757
+ // handle errors
758
+ }
759
+ ```
760
+
761
+ ---
762
+
763
+ ## HALT_HANDLER
764
+
765
+ A built-in handler that signals the ActionEngine to abort directive execution. The RuleEngine's interpreter checks `abortedBy === "halt"` as a controlled stop.
766
+
767
+ ```typescript
768
+ import { HALT_HANDLER } from "@statedelta-actions/rules";
769
+
770
+ const actionEngine = createActionEngine({
771
+ handlers: {
772
+ ...myHandlers,
773
+ halt: HALT_HANDLER,
774
+ },
775
+ });
776
+ ```
777
+
778
+ Usage in directives:
779
+
780
+ ```typescript
781
+ {
782
+ id: "guard",
783
+ priority: 500,
784
+ when: (ctx) => ctx.hp <= 0,
785
+ then: [
786
+ { type: "dispatch", target: "status", op: "set", value: "dead" },
787
+ { type: "halt" }, // stops all remaining rules
788
+ ],
789
+ }
790
+ ```
791
+
792
+ When halt fires:
793
+ - `result.aborted === true`
794
+ - `result.abortedBy === "halt"`
795
+ - `result.success === true` (halt is a controlled stop, not an error)
796
+ - Remaining rules are not evaluated
797
+
798
+ ---
799
+
800
+ ## createInvokerMiddleware
801
+
802
+ Built-in, opt-in middleware that injects `$invoker` metadata into the action scope:
803
+
804
+ ```typescript
805
+ import { createInvokerMiddleware } from "@statedelta-actions/rules";
806
+
807
+ const ruleEngine = createRuleEngine({
808
+ actionEngine,
809
+ middleware: [createInvokerMiddleware()],
810
+ });
811
+ ```
812
+
813
+ ### What it injects
814
+
815
+ ```typescript
816
+ {
817
+ $invoker: {
818
+ ruleId: "combat-heal",
819
+ priority: 100,
820
+ tags: ["combat"],
821
+ }
822
+ }
823
+ ```
824
+
825
+ ### Scope propagation
826
+
827
+ `$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.
828
+
829
+ ### Use case: audit trail
830
+
831
+ ```typescript
832
+ const auditHandler = {
833
+ analyze: () => ({ capabilities: [], dependencies: [] }),
834
+ execute: (_d, frame) => {
835
+ const invoker = frame.scope.$invoker;
836
+ auditLog.push({
837
+ ruleId: invoker.ruleId,
838
+ priority: invoker.priority,
839
+ timestamp: Date.now(),
840
+ });
841
+ return { ok: true };
842
+ },
843
+ };
844
+ ```
845
+
846
+ ---
847
+
848
+ ## Full API Reference
849
+
850
+ ### IRuleEngine\<TCtx\>
851
+
852
+ ```typescript
853
+ interface IRuleEngine<TCtx> {
854
+ // Registration
855
+ register(rules: readonly RuleDefinition<TCtx>[]): RuleRegisterResult;
856
+ unregister(id: string): boolean;
857
+
858
+ // Trigger evaluation
859
+ evaluate(ctx: TCtx): RuleEvaluationResult;
860
+ evaluateAsync(ctx: TCtx): Promise<RuleEvaluationResult>;
861
+
862
+ // Batch
863
+ beginBatch(): void;
864
+ endBatch(): RuleRegisterResult;
865
+ batch(fn: (engine: IRuleEngine<TCtx>) => void): RuleRegisterResult;
866
+
867
+ // JIT
868
+ compile(): void;
869
+
870
+ // Introspection
871
+ has(id: string): boolean; // accepts qualified IDs for sub-rules
872
+ readonly size: number; // top-level rules count
873
+
874
+ // Accessors
875
+ readonly actionEngine: IActionEngine<TCtx>;
876
+ readonly isAsync: boolean;
877
+ readonly compilationMode: "interpret" | "jit";
878
+ }
879
+ ```
880
+
881
+ ### RuleEngineConfig\<TCtx\>
882
+
883
+ ```typescript
884
+ interface RuleEngineConfig<TCtx> {
885
+ actionEngine: IActionEngine<TCtx>; // required
886
+ middleware?: readonly RuleMiddleware<TCtx>[]; // optional, default []
887
+ ruleHooks?: RuleHooks<TCtx>; // optional
888
+ maxSubRuleDepth?: number; // optional, default 10
889
+ mode?: "interpret" | "jit" | "auto"; // optional, default "auto"
890
+ autoJitThreshold?: number; // optional, default 8
891
+ }
892
+ ```
893
+
894
+ ### FrameCounters
895
+
896
+ ```typescript
897
+ interface FrameCounters {
898
+ rulesEvaluated: number;
899
+ rulesMatched: number;
900
+ rulesSkipped: number;
901
+ directivesApplied: number;
902
+ directivesSkipped: number;
903
+ subRunsCreated: number;
904
+ errors: number;
905
+ }
906
+ ```
907
+
908
+ ---
909
+
910
+ ## Use Case: RPG Combat Tick
911
+
912
+ A game tick that evaluates combat rules with sub-rules and halt.
913
+
914
+ ```typescript
915
+ interface GameCtx {
916
+ hp: number;
917
+ maxHp: number;
918
+ mp: number;
919
+ zone: string;
920
+ effects: string[];
921
+ log: string[];
922
+ }
923
+
924
+ // --- ActionEngine setup ---
925
+
926
+ const actionEngine = createActionEngine<GameCtx>({
927
+ handlers: {
928
+ dispatch: dispatchHandler,
929
+ emit: emitHandler,
930
+ log: logHandler,
931
+ halt: HALT_HANDLER,
932
+ },
933
+ });
934
+
935
+ // --- RuleEngine setup ---
936
+
937
+ const ruleEngine = createRuleEngine<GameCtx>({
938
+ actionEngine,
939
+ middleware: [createInvokerMiddleware()],
940
+ ruleHooks: {
941
+ beforeRule: (rule) => {
942
+ if (rule.priority < 50) return "skip";
943
+ },
944
+ },
945
+ });
946
+
947
+ // --- Rules ---
948
+
949
+ ruleEngine.register([
950
+ // High-priority death check — halts everything
951
+ {
952
+ id: "death-check",
953
+ priority: 500,
954
+ when: (ctx) => ctx.hp <= 0,
955
+ then: [
956
+ { type: "log", message: "dead — halting" },
957
+ { type: "halt" },
958
+ ],
959
+ },
960
+
961
+ // Combat zone rules with sub-rules
962
+ {
963
+ id: "combat-zone",
964
+ priority: 100,
965
+ when: (ctx) => ctx.zone === "combat",
966
+ then: [
967
+ { type: "dispatch", target: "hp", op: "dec", value: 15 },
968
+ { type: "emit", event: "damage-tick" },
969
+ ],
970
+ rules: [
971
+ {
972
+ id: "low-hp-heal",
973
+ when: (ctx) => ctx.hp < 50,
974
+ then: [
975
+ { type: "dispatch", target: "hp", op: "inc", value: 5 },
976
+ ],
977
+ },
978
+ ],
979
+ },
980
+
981
+ // MP regen
982
+ {
983
+ id: "mp-regen",
984
+ priority: 50,
985
+ when: (ctx) => ctx.mp < 100,
986
+ then: [
987
+ { type: "dispatch", target: "mp", op: "inc", value: 3 },
988
+ ],
989
+ },
990
+ ]);
991
+
992
+ // --- Tick ---
993
+
994
+ const ctx: GameCtx = { hp: 40, maxHp: 100, mp: 30, zone: "combat", effects: [], log: [] };
995
+
996
+ const result = ruleEngine.evaluate(ctx);
997
+ // combat-zone fires: hp 40->25, emits "damage-tick"
998
+ // low-hp-heal fires: hp 25->30
999
+ // mp-regen fires: mp 30->33
1000
+ ```
1001
+
1002
+ ---
1003
+
1004
+ ## Use Case: E-commerce Order Pipeline
1005
+
1006
+ An order processing pipeline with validation, tiered discounts, and fraud detection.
1007
+
1008
+ ```typescript
1009
+ interface OrderCtx {
1010
+ orderId: string;
1011
+ status: string;
1012
+ items: Array<{ sku: string; qty: number; price: number }>;
1013
+ subtotal: number;
1014
+ discount: number;
1015
+ total: number;
1016
+ customerTier: string;
1017
+ coupon: string | null;
1018
+ paymentMethod: string;
1019
+ flags: string[];
1020
+ log: string[];
1021
+ }
1022
+
1023
+ ruleEngine.register([
1024
+ // 1. Fraud detection — highest priority, halts pipeline
1025
+ {
1026
+ id: "fraud-check",
1027
+ priority: 500,
1028
+ when: (ctx) => ctx.subtotal > 1000 && ctx.customerTier === "bronze",
1029
+ then: [
1030
+ { type: "dispatch", target: "flags", op: "push", value: "fraud-review" },
1031
+ { type: "dispatch", target: "status", op: "set", value: "held" },
1032
+ { type: "halt" },
1033
+ ],
1034
+ },
1035
+
1036
+ // 2. Calculate subtotal
1037
+ {
1038
+ id: "calc-subtotal",
1039
+ priority: 400,
1040
+ when: (ctx) => ctx.status === "pending",
1041
+ then: [
1042
+ {
1043
+ type: "dispatch", target: "subtotal", op: "set", value: 0,
1044
+ resolve: (ctx) => ({
1045
+ value: ctx.items.reduce((sum, i) => sum + i.qty * i.price, 0),
1046
+ }),
1047
+ },
1048
+ { type: "dispatch", target: "status", op: "set", value: "validated" },
1049
+ ],
1050
+ },
1051
+
1052
+ // 3. Tiered discounts via sub-rules
1053
+ {
1054
+ id: "discount-engine",
1055
+ priority: 300,
1056
+ when: (ctx) => ctx.status === "validated" && ctx.discount === 0,
1057
+ then: [{ type: "log", message: "evaluating discounts" }],
1058
+ rules: [
1059
+ {
1060
+ id: "gold-discount",
1061
+ when: (ctx) => ctx.customerTier === "gold",
1062
+ then: [
1063
+ { type: "dispatch", target: "discount", op: "set", value: 0,
1064
+ resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.15) }) },
1065
+ ],
1066
+ },
1067
+ {
1068
+ id: "silver-discount",
1069
+ when: (ctx) => ctx.customerTier === "silver",
1070
+ then: [
1071
+ { type: "dispatch", target: "discount", op: "set", value: 0,
1072
+ resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.1) }) },
1073
+ ],
1074
+ },
1075
+ ],
1076
+ },
1077
+
1078
+ // 4. Payment
1079
+ {
1080
+ id: "process-payment",
1081
+ priority: 200,
1082
+ when: (ctx) => ctx.status === "validated",
1083
+ then: [
1084
+ { type: "dispatch", target: "total", op: "set", value: 0,
1085
+ resolve: (ctx) => ({ value: ctx.subtotal - ctx.discount }) },
1086
+ { type: "dispatch", target: "status", op: "set", value: "paid" },
1087
+ { type: "emit", event: "payment-processed" },
1088
+ ],
1089
+ },
1090
+ ]);
1091
+ ```
1092
+
1093
+ ---
1094
+
1095
+ ## Use Case: Deployment Approval Workflow
1096
+
1097
+ A CI/CD pipeline with submission, reviewer assignment, approval gates, and deploy strategy selection via sub-rules.
1098
+
1099
+ ```typescript
1100
+ interface WorkflowCtx {
1101
+ id: string;
1102
+ status: string;
1103
+ type: string;
1104
+ environment: string;
1105
+ author: string;
1106
+ reviewers: string[];
1107
+ approvals: string[];
1108
+ rejections: string[];
1109
+ requiredApprovals: number;
1110
+ riskScore: number;
1111
+ flags: string[];
1112
+ metadata: Record<string, unknown>;
1113
+ log: string[];
1114
+ }
1115
+
1116
+ ruleEngine.register([
1117
+ // 1. Rejection check — highest priority, halts
1118
+ {
1119
+ id: "check-rejections",
1120
+ priority: 500,
1121
+ when: (ctx) => ctx.status === "in-review" && ctx.rejections.length > 0,
1122
+ then: [
1123
+ { type: "dispatch", target: "status", op: "set", value: "rejected" },
1124
+ { type: "halt" },
1125
+ ],
1126
+ },
1127
+
1128
+ // 2. Submission with reviewer assignment via sub-rules
1129
+ {
1130
+ id: "submit",
1131
+ priority: 400,
1132
+ when: (ctx) => ctx.status === "draft",
1133
+ then: [
1134
+ { type: "dispatch", target: "status", op: "set", value: "submitted" },
1135
+ ],
1136
+ rules: [
1137
+ {
1138
+ id: "assign-feature-reviewers",
1139
+ when: (ctx) => ctx.type === "feature",
1140
+ then: [
1141
+ { type: "dispatch", target: "reviewers", op: "push", value: "tech-lead" },
1142
+ { type: "dispatch", target: "reviewers", op: "push", value: "senior-dev" },
1143
+ ],
1144
+ },
1145
+ {
1146
+ id: "assign-infra-reviewers",
1147
+ when: (ctx) => ctx.type === "infra",
1148
+ then: [
1149
+ { type: "dispatch", target: "reviewers", op: "push", value: "devops-lead" },
1150
+ { type: "dispatch", target: "reviewers", op: "push", value: "sre" },
1151
+ ],
1152
+ },
1153
+ ],
1154
+ },
1155
+
1156
+ // 3. Deploy strategy by environment + risk via sub-rules
1157
+ {
1158
+ id: "deploy",
1159
+ priority: 100,
1160
+ when: (ctx) => ctx.status === "approved",
1161
+ then: [{ type: "dispatch", target: "status", op: "set", value: "deploying" }],
1162
+ rules: [
1163
+ {
1164
+ id: "deploy-production",
1165
+ when: (ctx) => ctx.environment === "production",
1166
+ then: [{ type: "log", message: "production deploy" }],
1167
+ rules: [
1168
+ {
1169
+ id: "canary-deploy",
1170
+ when: (ctx) => ctx.riskScore > 50,
1171
+ then: [
1172
+ { type: "dispatch", target: "flags", op: "push", value: "canary" },
1173
+ ],
1174
+ },
1175
+ {
1176
+ id: "blue-green-deploy",
1177
+ when: (ctx) => ctx.riskScore <= 50,
1178
+ then: [
1179
+ { type: "dispatch", target: "flags", op: "push", value: "blue-green" },
1180
+ ],
1181
+ },
1182
+ ],
1183
+ },
1184
+ ],
1185
+ },
1186
+ ]);
1187
+ ```
1188
+
1189
+ ---
1190
+
1191
+ ## Performance Spectrum
1192
+
1193
+ ```
1194
+ Tier 0 (no hooks, no middleware) Full (hooks + middleware + sub-rules)
1195
+ ---------------------------------------------------------------------
1196
+ Zero allocations for hooks evalCtx created per evaluate()
1197
+ No try/catch try/catch per hook
1198
+ No params variable params allocated + middleware pipeline
1199
+ Minimal loop Full governance pipeline
1200
+ ```
1201
+
1202
+ 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).
1203
+
1204
+ ---
1205
+
1206
+ ## Internal Design Notes
1207
+
1208
+ For detailed internal architecture, see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
1209
+
1210
+ ### Key decisions
1211
+
1212
+ - **Rules are hidden actions** with prefix `rule:`. Sub-rules use `.` separator.
1213
+ - **Sub-rules are always interpreted** — JIT compiles only the main loop.
1214
+ - **Single exit point** in the interpreter — `onRulesComplete` called exactly once.
1215
+ - **Closure counter + noop swap** for auto-promote — zero overhead post-JIT.
1216
+ - **`Object.assign` in-place** for middleware — 1 allocation vs N+1.
1217
+ - **Immutability by convention** — no `Object.freeze()` (measurable cost in hot paths).
1218
+ - **Events are a separate package** — `@statedelta-actions/events`. The RuleEngine does not know about events.