Repository: CacheControl/json-rules-engine
Pull request #339 (2023-07-10 17:47:09)

Condition Sharing
This introduces the concept of Condition sharing which allows regular conditions that are given a well-known name and to be added to the engine. Rules can reference the condition by including a `condition` property on a condition.

example based on #296 
```js
engine.setCondition('isMinor', {
  all: [
    {
        fact: 'age',
        operator: 'lessThan',
       value: { fact: 'drinkingAge' }
    }
  ]
});

engine.addRule({
   conditions: {
      all: [
          { not: { condition: 'isMinor' } },
         {
             fact: 'currentTime',
             operator: 'lessThanInclusive',
             value: { fact: 'lastCall' }
         }
     ]
  },
    event: {
      type: 'AlcoholicBeverageSelection'
   }
});

// condition references can be top-level conditions so it's possible for a rule to just be a condition reference.
engine.addRule({
 conditions: {
    condition: 'isMinor'
  },
  event: {
     type: 'SoftDrinkSelection'
  }
})
```

This addresses #336, #296, and #263

A few things related to this:

### Why do we need this at-all
The ability to capture common conditions and compose rules is useful as a first-class function of this rules engine. As rules reach increased levels of complexity common patterns emerge that should be re-used rather than repeated. One option to do this is to add a "pre-processing" step outside the rules engine that would build up rules from component parts. However this means that someone inspecting the rules as they're run would need to understand the behavior of this pre-processing. Additionally having this be supported by the rules engine allows for more universal editing tools to adopt this as something that can be edited. 

### Condition references vs. rule-chaining
The primary difference here is that the composition can be communicated using JSON because conditions are fully expressible as JSON. This prevents the javascript code from needing to know about specific rules to setup specific event handlers. This allows us to cleanly separate javascript code that configures the rules engine from the rules themselves. Additionally there is no need to specify rule priority when using conditions as the condition references will be resolved at runtime.

### Why condition references and not something with facts
I had originally suggested using a "conditional fact" or other construct to capture the idea of re-used logic. This was nice because it matched concepts that already existed in the rules engine. However it fails the equivalency test. Extracting re-used conditions into a fact would cause the rule results to be different. I liked that the use of Conditions and the approach of de-referencing the condition references at runtime would keep the same structure to the rule results even after refactoring.

### Concern with duplicated work.
Condition references are dereferenced and evaluated within each rule they're part of. That means that if a condition is used in 10 rules it will be evaluated 10 times. I'm not concerned about this as the majority of the work will be captured by the Fact value caching in the almanac. The work that is left to be done ought to be very quick. While the possibility of result caching does exist as a future improvement adding it here seemed unnecessarily complex.


Patch:
diff --git a/docs/engine.md b/docs/engine.md
index 46d0a1b..7665fa0 100644
--- a/docs/engine.md
+++ b/docs/engine.md
@@ -12,6 +12,8 @@ The Engine stores and executes rules, emits events, and maintains state.
     * [engine.removeRule(Rule instance | String ruleId)](#engineremoverulerule-instance)
     * [engine.addOperator(String operatorName, Function evaluateFunc(factValue, jsonValue))](#engineaddoperatorstring-operatorname-function-evaluatefuncfactvalue-jsonvalue)
     * [engine.removeOperator(String operatorName)](#engineremoveoperatorstring-operatorname)
+    * [engine.setCondition(String name, Object conditions)](#enginesetconditionstring-name-object-conditions)
+    * [engine.removeCondition(String name)](#engineremovecondtionstring-name)
     * [engine.run([Object facts], [Object options]) -&gt; Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []})](#enginerunobject-facts-object-options---promise--events--failureevents--almanac-almanac-results--failureresults-)
     * [engine.stop() -&gt; Engine](#enginestop---engine)
       * [engine.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult)
@@ -43,6 +45,11 @@ let engine = new Engine([Array rules], options)
 an exception is thrown.  Turning this option on will cause the engine to treat
 undefined facts as `undefined`.  (default: false)
 
+`allowUndefinedConditions` - By default, when a running engine encounters a
+condition reference that cannot be resolved an exception is thrown. Turning
+this option on will cause the engine to treat unresolvable condition references
+as failed conditions. (default: false)
+
 `pathResolver` - Allows a custom object path resolution library to be used. (default: `json-path` syntax). See [custom path resolver](./rules.md#condition-helpers-custom-path-resolver) docs.
 
 ### engine.addFact(String id, Function [definitionFunc], Object [options])
@@ -172,6 +179,71 @@ engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
 engine.removeOperator('startsWithLetter');
 ```
 
+### engine.setCondition(String name, Object conditions)
+
+Adds or updates a condition to the engine. Rules may include references to this condition. Conditions must start with `all`, `any`, `not`, or reference a condition.
+
+```javascript
+engine.setCondition('validLogin', {
+  all: [
+    {
+      operator: 'notEqual',
+      fact: 'loginToken',
+      value: null
+    },
+    {
+      operator: 'greaterThan',
+      fact: 'loginToken',
+      path: '$.expirationTime',
+      value: { fact: 'now' }
+    }
+  ]
+});
+
+engine.addRule({
+  condtions: {
+    all: [
+      {
+        condition: 'validLogin'
+      },
+      {
+        operator: 'contains',
+        fact: 'loginToken',
+        path: '$.role',
+        value: 'admin'
+      }
+    ]
+  },
+  event: {
+    type: 'AdminAccessAllowed'
+  }
+})
+
+```
+
+### engine.removeCondition(String name)
+
+Removes the condition that was previously added.
+
+```javascript
+engine.setCondition('validLogin', {
+  all: [
+    {
+      operator: 'notEqual',
+      fact: 'loginToken',
+      value: null
+    },
+    {
+      operator: 'greaterThan',
+      fact: 'loginToken',
+      path: '$.expirationTime',
+      value: { fact: 'now' }
+    }
+  ]
+});
+
+engine.removeCondition('validLogin');
+```
 
 
 ### engine.run([Object facts], [Object options]) -> Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []})
diff --git a/docs/rules.md b/docs/rules.md
index 187c230..518989e 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -14,7 +14,8 @@ Rules contain a set of _conditions_ and a single _event_.  When the engine is ru
     * [toJSON(Boolean stringify = true)](#tojsonboolean-stringify--true)
 * [Conditions](#conditions)
     * [Basic conditions](#basic-conditions)
-    * [Boolean expressions: all and any](#boolean-expressions-all-and-any)
+    * [Boolean expressions: all, any, and not](#boolean-expressions-all-any-and-not)
+    * [Condition Reference](#condition-reference)
     * [Condition helpers: params](#condition-helpers-params)
     * [Condition helpers: path](#condition-helpers-path)
     * [Condition helpers: custom path resolver](#condition-helpers-custom-path-resolver)
@@ -136,7 +137,7 @@ See the [hello-world](../examples/01-hello-world.js) example.
 
 ### Boolean expressions: `all`, `any`, and `not`
 
-Each rule's conditions *must* have an `all` or `any` operator containing an array of conditions at its root or a `not` operator containing a single condition.  The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`.  The `any` operator only requires one condition to be truthy for the rule to succeed. The `not` operator will negate whatever condition it contains.
+Each rule's conditions *must* have an `all` or `any` operator containing an array of conditions at its root, a `not` operator containing a single condition, or a condition reference. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`.  The `any` operator only requires one condition to be truthy for the rule to succeed. The `not` operator will negate whatever condition it contains.
 
 ```js
 // all:
@@ -174,7 +175,30 @@ let rule = new Rule({
 })
 ```
 
-Notice in the second example how `all`, `any`, and 'not' can be nested within one another to produce complex boolean expressions.  See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example.
+Notice in the second example how `all`, `any`, and `not` can be nested within one another to produce complex boolean expressions.  See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example.
+
+### Condition Reference
+
+Rules may reference conditions based on their name.
+
+```js
+let rule = new Rule({
+  conditions: {
+    all: [
+      { condition: 'conditionName' },
+      { /* additional condition */ }
+    ]
+  }
+})
+```
+
+Before running the rule the condition should be added to the engine.
+
+```js
+engine.setCondition('conditionName', { /* conditions */ });
+```
+
+Conditions must start with `all`, `any`, `not`, or reference a condition.
 
 ### Condition helpers: `params`
 
diff --git a/examples/10-condition-sharing.js b/examples/10-condition-sharing.js
new file mode 100644
index 0000000..29fe2e3
--- /dev/null
+++ b/examples/10-condition-sharing.js
@@ -0,0 +1,139 @@
+'use strict'
+/*
+ * This is an advanced example demonstrating rules that re-use a condition defined
+ * in the engine.
+ *
+ * Usage:
+ *   node ./examples/10-condition-sharing.js
+ *
+ * For detailed output:
+ *   DEBUG=json-rules-engine node ./examples/10-condition-sharing.js
+ */
+
+require('colors')
+const { Engine } = require('json-rules-engine')
+
+async function start () {
+  /**
+   * Setup a new engine
+   */
+  const engine = new Engine()
+
+  /**
+   * Condition that will be used to determine if a user likes screwdrivers
+   */
+  engine.setCondition('screwdriverAficionado', {
+    all: [
+      {
+        fact: 'drinksOrangeJuice',
+        operator: 'equal',
+        value: true
+      },
+      {
+        fact: 'enjoysVodka',
+        operator: 'equal',
+        value: true
+      }
+    ]
+  })
+
+  /**
+   * Rule for identifying people who should be invited to a screwdriver social
+   * - Only invite people who enjoy screw drivers
+   * - Only invite people who are sociable
+   */
+  const inviteRule = {
+    conditions: {
+      all: [
+        {
+          condition: 'screwdriverAficionado'
+        },
+        {
+          fact: 'isSociable',
+          operator: 'equal',
+          value: true
+        }
+      ]
+    },
+    event: { type: 'invite-to-screwdriver-social' }
+  }
+  engine.addRule(inviteRule)
+
+  /**
+   * Rule for identifying people who should be invited to the other social
+   * - Only invite people who don't enjoy screw drivers
+   * - Only invite people who are sociable
+   */
+  const otherInviteRule = {
+    conditions: {
+      all: [
+        {
+          not: {
+            condition: 'screwdriverAficionado'
+          }
+        },
+        {
+          fact: 'isSociable',
+          operator: 'equal',
+          value: true
+        }
+      ]
+    },
+    event: { type: 'invite-to-other-social' }
+  }
+  engine.addRule(otherInviteRule)
+
+  /**
+   * Register listeners with the engine for rule success and failure
+   */
+  engine
+    .on('success', async (event, almanac) => {
+      const accountId = await almanac.factValue('accountId')
+      console.log(
+        `${accountId}` +
+          'DID'.green +
+          ` meet conditions for the ${event.type.underline} rule.`
+      )
+    })
+    .on('failure', async (event, almanac) => {
+      const accountId = await almanac.factValue('accountId')
+      console.log(
+        `${accountId} did ` +
+          'NOT'.red +
+          ` meet conditions for the ${event.type.underline} rule.`
+      )
+    })
+
+  // define fact(s) known at runtime
+  let facts = {
+    accountId: 'washington',
+    drinksOrangeJuice: true,
+    enjoysVodka: true,
+    isSociable: true
+  }
+
+  // first run, using washington's facts
+  await engine.run(facts)
+
+  facts = {
+    accountId: 'jefferson',
+    drinksOrangeJuice: true,
+    enjoysVodka: false,
+    isSociable: true,
+    accountInfo: {}
+  }
+
+  // second run, using jefferson's facts; facts & evaluation are independent of the first run
+  await engine.run(facts)
+}
+
+start()
+
+/*
+ * OUTPUT:
+ *
+ * washington DID meet conditions for the invite-to-screwdriver-social rule.
+ * washington did NOT meet conditions for the invite-to-other-social rule.
+ * jefferson did NOT meet conditions for the invite-to-screwdriver-social rule.
+ * jefferson DID meet conditions for the invite-to-other-social rule.
+ */
diff --git a/examples/package.json b/examples/package.json
index 2af5d11..1758ee5 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -10,6 +10,6 @@
   "author": "Cache Hamm <cache.hamm@gmail.com>",
   "license": "ISC",
   "dependencies": {
-    "json-rules-engine": "6.0.0-alpha-3"
+    "json-rules-engine": "../"
   }
 }
diff --git a/src/condition.js b/src/condition.js
index e7a3770..b5f8102 100644
--- a/src/condition.js
+++ b/src/condition.js
@@ -21,7 +21,7 @@ export default class Condition {
       } else {
         this[booleanOperator] = new Condition(subConditions)
       }
-    } else {
+    } else if (!Object.prototype.hasOwnProperty.call(properties, 'condition')) {
       if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) throw new Error('Condition: constructor "fact" property required')
       if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) throw new Error('Condition: constructor "operator" property required')
       if (!Object.prototype.hasOwnProperty.call(properties, 'value')) throw new Error('Condition: constructor "value" property required')
@@ -54,6 +54,8 @@ export default class Condition {
       } else {
         props[oper] = this[oper].toJSON(false)
       }
+    } else if (this.isConditionReference()) {
+      props.condition = this.condition
     } else {
       props.operator = this.operator
       props.value = this.value
@@ -147,4 +149,12 @@ export default class Condition {
   isBooleanOperator () {
     return Condition.booleanOperator(this) !== undefined
   }
+
+  /**
+   * Whether the condition represents a reference to a condition
+   * @returns {Boolean}
+   */
+  isConditionReference () {
+    return Object.prototype.hasOwnProperty.call(this, 'condition')
+  }
 }
diff --git a/src/engine.js b/src/engine.js
index 3bd958e..6434d53 100644
--- a/src/engine.js
+++ b/src/engine.js
@@ -7,6 +7,7 @@ import Almanac from './almanac'
 import EventEmitter from 'eventemitter2'
 import defaultOperators from './engine-default-operators'
 import debug from './debug'
+import Condition from './condition'
 
 export const READY = 'READY'
 export const RUNNING = 'RUNNING'
@@ -21,9 +22,11 @@ class Engine extends EventEmitter {
     super()
     this.rules = []
     this.allowUndefinedFacts = options.allowUndefinedFacts || false
+    this.allowUndefinedConditions = options.allowUndefinedConditions || false
     this.pathResolver = options.pathResolver
     this.operators = new Map()
     this.facts = new Map()
+    this.conditions = new Map()
     this.status = READY
     rules.map(r => this.addRule(r))
     defaultOperators.map(o => this.addOperator(o))
@@ -92,6 +95,31 @@ class Engine extends EventEmitter {
     return ruleRemoved
   }
 
+  /**
+   * sets a condition that can be referenced by the given name.
+   * If a condition with the given name has already been set this will replace it.
+   * @param {string} name - the name of the condition to be referenced by rules.
+   * @param {object} conditions - the conditions to use when the condition is referenced.
+   */
+  setCondition (name, conditions) {
+    if (!name) throw new Error('Engine: setCondition() requires name')
+    if (!conditions) throw new Error('Engine: setCondition() requires conditions')
+    if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not') && !Object.prototype.hasOwnProperty.call(conditions, 'condition')) {
+      throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "condition"')
+    }
+    this.conditions.set(name, new Condition(conditions))
+    return this
+  }
+
+  /**
+   * Removes a condition that has previously been added to this engine
+   * @param {string} name - the name of the condition to remove.
+   * @returns true if the condition existed, otherwise false
+   */
+  removeCondition (name) {
+    return this.conditions.delete(name)
+  }
+
   /**
    * Add a custom operator definition
    * @param {string}   operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
diff --git a/src/rule.js b/src/rule.js
index c4f488b..a26e035 100644
--- a/src/rule.js
+++ b/src/rule.js
@@ -3,6 +3,7 @@
 import Condition from './condition'
 import RuleResult from './rule-result'
 import debug from './debug'
+import deepClone from 'clone'
 import EventEmitter from 'eventemitter2'
 
 class Rule extends EventEmitter {
@@ -70,8 +71,8 @@ class Rule extends EventEmitter {
    * @param {object} conditions - conditions, root element must be a boolean operator
    */
   setConditions (conditions) {
-    if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not')) {
-      throw new Error('"conditions" root must contain a single instance of "all", "any", or "not"')
+    if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not') && !Object.prototype.hasOwnProperty.call(conditions, 'condition')) {
+      throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "condition"')
     }
     this.conditions = new Condition(conditions)
     return this
@@ -188,7 +189,9 @@ class Rule extends EventEmitter {
      * @return {Promise(true|false)} - resolves with the result of the condition evaluation
      */
     const evaluateCondition = (condition) => {
-      if (condition.isBooleanOperator()) {
+      if (condition.isConditionReference()) {
+        return realize(this.engine.conditions.get(condition.condition), condition)
+      } else if (condition.isBooleanOperator()) {
         const subConditions = condition[condition.operator]
         let comparisonPromise
         if (condition.operator === 'all') {
@@ -309,6 +312,23 @@ class Rule extends EventEmitter {
       return prioritizeAndRun([condition], 'not').then(result => !result)
     }
 
+    const realize = (condition, conditionReference) => {
+      if (!condition) {
+        if (this.engine.allowUndefinedConditions) {
+          // undefined conditions always fail
+          conditionReference.result = false
+          return Promise.resolve(false)
+        } else {
+          throw new Error(`No condition ${conditionReference.condition} exists`)
+        }
+      } else {
+        // project the referenced condition onto reference object and evaluate it.
+        delete conditionReference.condition
+        Object.assign(conditionReference, deepClone(condition))
+        return evaluateCondition(conditionReference)
+      }
+    }
+
     /**
      * Emits based on rule evaluation result, and decorates ruleResult with 'result' property
      * @param {RuleResult} ruleResult
diff --git a/types/index.d.ts b/types/index.d.ts
index 3cf93c0..ec28eed 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -1,5 +1,6 @@
 export interface EngineOptions {
   allowUndefinedFacts?: boolean;
+  allowUndefinedConditions?: boolean;
   pathResolver?: PathResolver;
 }
 
@@ -23,6 +24,9 @@ export class Engine {
   removeRule(ruleOrName: Rule | string): boolean;
   updateRule(rule: Rule): void;
 
+  setCondition(name: string, conditions: TopLevelCondition): this;
+  removeCondition(name: string): boolean;
+
   addOperator(operator: Operator): Map<string, Operator>;
   addOperator<A, B>(
     operatorName: string,
@@ -98,10 +102,7 @@ export interface Event {
   params?: Record<string, any>;
 }
 
-export type PathResolver = (
-  value: object,
-  path: string,
-) => any;
+export type PathResolver = (value: object, path: string) => any;
 
 export type EventHandler = (
   event: Event,
@@ -156,7 +157,24 @@ interface ConditionProperties {
 }
 
 type NestedCondition = ConditionProperties | TopLevelCondition;
-type AllConditions = { all: NestedCondition[]; name?: string; priority?: number; };
-type AnyConditions = { any: NestedCondition[]; name?: string; priority?: number; };
-type NotConditions = { not: NestedCondition; name?: string; priority?: number; };
-export type TopLevelCondition = AllConditions | AnyConditions | NotConditions;
+type AllConditions = {
+  all: NestedCondition[];
+  name?: string;
+  priority?: number;
+};
+type AnyConditions = {
+  any: NestedCondition[];
+  name?: string;
+  priority?: number;
+};
+type NotConditions = { not: NestedCondition; name?: string; priority?: number };
+type ConditionReference = {
+  condition: string;
+  name?: string;
+  priority?: number;
+};
+export type TopLevelCondition =
+  | AllConditions
+  | AnyConditions
+  | NotConditions
+  | ConditionReference;


Test patch:
diff --git a/test/engine-condition.test.js b/test/engine-condition.test.js
new file mode 100644
index 0000000..d4867b9
--- /dev/null
+++ b/test/engine-condition.test.js
@@ -0,0 +1,280 @@
+'use strict'
+
+import sinon from 'sinon'
+import engineFactory from '../src/index'
+
+describe('Engine: condition', () => {
+  let engine
+  let sandbox
+  before(() => {
+    sandbox = sinon.createSandbox()
+  })
+  afterEach(() => {
+    sandbox.restore()
+  })
+
+  describe('setCondition()', () => {
+    describe('validations', () => {
+      beforeEach(() => {
+        engine = engineFactory()
+      })
+      it('throws an exception for invalid root conditions', () => {
+        expect(engine.setCondition.bind(engine, 'test', { foo: true })).to.throw(
+          /"conditions" root must contain a single instance of "all", "any", "not", or "condition"/
+        )
+      })
+    })
+  })
+
+  describe('undefined condition', () => {
+    const sendEvent = {
+      type: 'checkSending',
+      params: {
+        sendRetirementPayment: true
+      }
+    }
+
+    const sendConditions = {
+      all: [
+        { condition: 'over60' },
+        {
+          fact: 'isRetired',
+          operator: 'equal',
+          value: true
+        }
+      ]
+    }
+
+    describe('allowUndefinedConditions: true', () => {
+      let eventSpy
+      beforeEach(() => {
+        eventSpy = sandbox.spy()
+        const sendRule = factories.rule({
+          conditions: sendConditions,
+          event: sendEvent
+        })
+        engine = engineFactory([sendRule], { allowUndefinedConditions: true })
+
+        engine.addFact('isRetired', true)
+        engine.on('failure', eventSpy)
+      })
+
+      it('evaluates undefined conditions as false', async () => {
+        await engine.run()
+        expect(eventSpy).to.have.been.called()
+      })
+    })
+
+    describe('allowUndefinedConditions: false', () => {
+      beforeEach(() => {
+        const sendRule = factories.rule({
+          conditions: sendConditions,
+          event: sendEvent
+        })
+        engine = engineFactory([sendRule], { allowUndefinedConditions: false })
+
+        engine.addFact('isRetired', true)
+      })
+
+      it('throws error during run', async () => {
+        try {
+          await engine.run()
+        } catch (error) {
+          expect(error.message).to.equal('No condition over60 exists')
+        }
+      })
+    })
+  })
+
+  describe('supports condition shared across multiple rules', () => {
+    const name = 'over60'
+    const condition = {
+      all: [
+        {
+          fact: 'age',
+          operator: 'greaterThanInclusive',
+          value: 60
+        }
+      ]
+    }
+
+    const sendEvent = {
+      type: 'checkSending',
+      params: {
+        sendRetirementPayment: true
+      }
+    }
+
+    const sendConditions = {
+      all: [
+        { condition: name },
+        {
+          fact: 'isRetired',
+          operator: 'equal',
+          value: true
+        }
+      ]
+    }
+
+    const outreachEvent = {
+      type: 'triggerOutreach'
+    }
+
+    const outreachConditions = {
+      all: [
+        { condition: name },
+        {
+          fact: 'requestedOutreach',
+          operator: 'equal',
+          value: true
+        }
+      ]
+    }
+
+    let eventSpy
+    let ageSpy
+    let isRetiredSpy
+    let requestedOutreachSpy
+    beforeEach(() => {
+      eventSpy = sandbox.spy()
+      ageSpy = sandbox.stub()
+      isRetiredSpy = sandbox.stub()
+      requestedOutreachSpy = sandbox.stub()
+      engine = engineFactory()
+
+      const sendRule = factories.rule({
+        conditions: sendConditions,
+        event: sendEvent
+      })
+      engine.addRule(sendRule)
+
+      const outreachRule = factories.rule({
+        conditions: outreachConditions,
+        event: outreachEvent
+      })
+      engine.addRule(outreachRule)
+
+      engine.setCondition(name, condition)
+
+      engine.addFact('age', ageSpy)
+      engine.addFact('isRetired', isRetiredSpy)
+      engine.addFact('requestedOutreach', requestedOutreachSpy)
+      engine.on('success', eventSpy)
+    })
+
+    it('emits all events when all conditions are met', async () => {
+      ageSpy.returns(65)
+      isRetiredSpy.returns(true)
+      requestedOutreachSpy.returns(true)
+      await engine.run()
+      expect(eventSpy)
+        .to.have.been.calledWith(sendEvent)
+        .and.to.have.been.calledWith(outreachEvent)
+    })
+
+    it('expands condition in rule results', async () => {
+      ageSpy.returns(65)
+      isRetiredSpy.returns(true)
+      requestedOutreachSpy.returns(true)
+      const { results } = await engine.run()
+      const nestedCondition = {
+        'conditions.all[0].all[0].fact': 'age',
+        'conditions.all[0].all[0].operator': 'greaterThanInclusive',
+        'conditions.all[0].all[0].value': 60
+      }
+      expect(results[0]).to.nested.include(nestedCondition)
+      expect(results[1]).to.nested.include(nestedCondition)
+    })
+  })
+
+  describe('nested condition', () => {
+    const name1 = 'over60'
+    const condition1 = {
+      all: [
+        {
+          fact: 'age',
+          operator: 'greaterThanInclusive',
+          value: 60
+        }
+      ]
+    }
+
+    const name2 = 'earlyRetirement'
+    const condition2 = {
+      all: [
+        { not: { condition: name1 } },
+        {
+          fact: 'isRetired',
+          operator: 'equal',
+          value: true
+        }
+      ]
+    }
+
+    const outreachEvent = {
+      type: 'triggerOutreach'
+    }
+
+    const outreachConditions = {
+      all: [
+        { condition: name2 },
+        {
+          fact: 'requestedOutreach',
+          operator: 'equal',
+          value: true
+        }
+      ]
+    }
+
+    let eventSpy
+    let ageSpy
+    let isRetiredSpy
+    let requestedOutreachSpy
+    beforeEach(() => {
+      eventSpy = sandbox.spy()
+      ageSpy = sandbox.stub()
+      isRetiredSpy = sandbox.stub()
+      requestedOutreachSpy = sandbox.stub()
+      engine = engineFactory()
+
+      const outreachRule = factories.rule({
+        conditions: outreachConditions,
+        event: outreachEvent
+      })
+      engine.addRule(outreachRule)
+
+      engine.setCondition(name1, condition1)
+
+      engine.setCondition(name2, condition2)
+
+      engine.addFact('age', ageSpy)
+      engine.addFact('isRetired', isRetiredSpy)
+      engine.addFact('requestedOutreach', requestedOutreachSpy)
+      engine.on('success', eventSpy)
+    })
+
+    it('emits all events when all conditions are met', async () => {
+      ageSpy.returns(55)
+      isRetiredSpy.returns(true)
+      requestedOutreachSpy.returns(true)
+      await engine.run()
+      expect(eventSpy).to.have.been.calledWith(outreachEvent)
+    })
+
+    it('expands condition in rule results', async () => {
+      ageSpy.returns(55)
+      isRetiredSpy.returns(true)
+      requestedOutreachSpy.returns(true)
+      const { results } = await engine.run()
+      const nestedCondition = {
+        'conditions.all[0].all[0].not.all[0].fact': 'age',
+        'conditions.all[0].all[0].not.all[0].operator': 'greaterThanInclusive',
+        'conditions.all[0].all[0].not.all[0].value': 60,
+        'conditions.all[0].all[1].fact': 'isRetired',
+        'conditions.all[0].all[1].operator': 'equal',
+        'conditions.all[0].all[1].value': true
+      }
+      expect(results[0]).to.nested.include(nestedCondition)
+    })
+  })
+})
diff --git a/test/rule.test.js b/test/rule.test.js
index 8ee08fe..fc3357b 100644
--- a/test/rule.test.js
+++ b/test/rule.test.js
@@ -109,7 +109,7 @@ describe('Rule', () => {
   describe('setConditions()', () => {
     describe('validations', () => {
       it('throws an exception for invalid root conditions', () => {
-        expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all", "any", or "not"/)
+        expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all", "any", "not", or "condition"/)
       })
     })
   })