Skip to content

Rules Engine

The rules engine is the heart of Presage. It evaluates declarative conditions against the current UserContext and returns the appropriate action.

Each rule targets a specific adaptation point (identified by adaptationId). When a component resolves that adaptation point, the engine:

  1. Filters rules by adaptationId
  2. Sorts by priority (highest first)
  3. Evaluates each rule’s conditions against the UserContext
  4. Returns the action of the first matching rule
  5. Returns null if no rule matches (the component falls back to its default variant)
const rule: AdaptationRule = {
id: 'enterprise-advanced-dashboard',
adaptationId: 'dashboard',
priority: 10,
conditions: {
all: [
{ field: 'traits.plan', operator: 'eq', value: 'enterprise' },
{ field: 'signals.sessionCount', operator: 'gte', value: 10 },
],
},
action: { type: 'show', variantId: 'advanced' },
}
OperatorDescriptionExample
eqStrict equality (===){ field: 'traits.role', operator: 'eq', value: 'admin' }
neqStrict inequality (!==){ field: 'traits.plan', operator: 'neq', value: 'free' }

All numeric operators require both the field value and the comparison value to be numbers.

OperatorDescriptionExample
gtGreater than{ field: 'signals.sessionCount', operator: 'gt', value: 5 }
gteGreater than or equal{ field: 'signals.totalEvents', operator: 'gte', value: 100 }
ltLess than{ field: 'signals.daysSinceSignup', operator: 'lt', value: 7 }
lteLess than or equal{ field: 'traits.companySize', operator: 'lte', value: 50 }
OperatorDescriptionExample
inValue is in the provided array{ field: 'traits.plan', operator: 'in', value: ['pro', 'enterprise'] }
notInValue is not in the provided array{ field: 'traits.role', operator: 'notIn', value: ['viewer', 'guest'] }
OperatorDescriptionExample
containsString includes substring, or array includes element{ field: 'traits.company', operator: 'contains', value: 'Corp' }
notContainsInverse of contains{ field: 'traits.locale', operator: 'notContains', value: 'zh' }
OperatorDescriptionExample
existsValue is not undefined or null{ field: 'traits.company', operator: 'exists', value: true }
notExistsValue is undefined or null{ field: 'traits.signupDate', operator: 'notExists', value: true }
OperatorDescriptionExample
betweenNumber is within [min, max] (inclusive){ field: 'signals.sessionCount', operator: 'between', value: [5, 20] }
OperatorDescriptionExample
matchesString matches a regular expression{ field: 'traits.company', operator: 'matches', value: '^Acme.*' }

Every condition in the array must match:

conditions: {
all: [
{ field: 'traits.role', operator: 'eq', value: 'admin' },
{ field: 'traits.plan', operator: 'eq', value: 'enterprise' },
{ field: 'signals.sessionCount', operator: 'gte', value: 5 },
],
}

At least one condition must match:

conditions: {
any: [
{ field: 'maturity', operator: 'eq', value: 'new' },
{ field: 'maturity', operator: 'eq', value: 'onboarding' },
],
}

Inverts the result of a condition or group:

conditions: {
not: { field: 'traits.plan', operator: 'eq', value: 'free' },
}

Groups can be nested to express complex logic:

// (admin AND enterprise) OR (power user with 50+ sessions)
conditions: {
any: [
{
all: [
{ field: 'traits.role', operator: 'eq', value: 'admin' },
{ field: 'traits.plan', operator: 'eq', value: 'enterprise' },
],
},
{
all: [
{ field: 'maturity', operator: 'eq', value: 'power' },
{ field: 'signals.sessionCount', operator: 'gte', value: 50 },
],
},
],
}

Rules are evaluated from highest priority to lowest. The first match wins.

const rules = [
{
id: 'vip-override',
adaptationId: 'dashboard',
priority: 100, // Evaluated first
conditions: { all: [{ field: 'traits.role', operator: 'eq', value: 'vip' }] },
action: { type: 'show', variantId: 'vip-dashboard' },
},
{
id: 'enterprise-dashboard',
adaptationId: 'dashboard',
priority: 50, // Evaluated second
conditions: { all: [{ field: 'traits.plan', operator: 'eq', value: 'enterprise' }] },
action: { type: 'show', variantId: 'advanced' },
},
{
id: 'default-dashboard',
adaptationId: 'dashboard',
priority: 1, // Catch-all
conditions: {}, // Empty group matches everything
action: { type: 'show', variantId: 'standard' },
},
]

Fields use dot-notation to access nested values in the UserContext:

PathResolves to
maturitycontext.maturity (a string)
traits.rolecontext.traits.role
traits.companySizecontext.traits.companySize
signals.sessionCountcontext.signals.sessionCount
signals.featureUsage.exportcontext.signals.featureUsage.export
signals.clickMap.nav-settingscontext.signals.clickMap['nav-settings']

Selects a specific variant to render:

action: { type: 'show', variantId: 'guided-tour' }

Hides the adaptation point entirely:

action: { type: 'hide' }

Reorders items (used with AdaptiveNav):

action: { type: 'reorder', order: ['analytics', 'settings', 'home'] }

Passes arbitrary props to the matched component:

action: { type: 'modify', props: { showBetaBadge: true, maxItems: 10 } }
  1. Name rules descriptivelynew-user-guided-tour is clearer than rule-1
  2. Use priority gaps — Leave room between priorities (10, 20, 30) so you can insert rules later without renumbering
  3. Keep conditions shallow — Deeply nested boolean trees are hard to debug; prefer flatter structures
  4. Test rules in isolation — The RulesEngine class can be instantiated directly for unit testing
  5. Prefer in over multiple eq{ operator: 'in', value: ['pro', 'enterprise'] } is cleaner than two any conditions