Rules Engine — Condition AST + Actions
There is no Rules DSL. Rules are stored as structured JSON documents (an explicit AST of conditions plus a flat list of action objects) and authored either by the in-app rule builder UI or by direct JSON. There is no custom language, no parser, and no text grammar —
RuleEvaluatorwalks the JSON AST against a fixed registry of operators andRulesService.executeAction()dispatches against a fixed registry of action types. This document describes the actual implemented surface inapps/hub/src/services/rules/. There is no Zod schema for the rule shape at this commit.
The Rules Engine operates directly on the unified data — no AI required for common automations. Rules are fast, reliable, and transparent.
Shape
Rules are stored as JSON in the rules table. Each rule has:
trigger_event_types— list of NATS event type patterns that trigger evaluation (supportsdomain.*wildcards, seeRulesService.matchesEventType)condition_ast— aConditionNodetree (see below)actions— an array ofRuleActionobjects
Condition AST
Implemented in apps/hub/src/services/rules/types.ts and evaluated in RuleEvaluator.ts:
type ConditionNode =
| { type: 'AND'; children: ConditionNode[] }
| { type: 'OR'; children: ConditionNode[] }
| { type: 'NOT'; child: ConditionNode }
| { type: 'OPERATOR'; operator: string; params: Record<string, unknown> };
Evaluation is performed against an EvaluationContext:
interface EvaluationContext {
event: { type: string; data: Record<string, unknown>; correlationId: string };
person?: { id: string; name: string; tags: string[]; emails: string[] };
channel?: { id: string; type: string };
location?: { id: string; name: string };
ownerId: string;
presentPlane?: PresentPlaneState; // optional, from StreamProcessingService / Redis
}
Built-In Condition Operators
All operators below are implemented directly in RuleEvaluator.ts or operators/. Unknown operators log a warning and evaluate to false.
Synchronous operators
operator | params | Source |
|---|---|---|
person.InGroup | { group: string } — matches ctx.person.tags | RuleEvaluator.ts |
time.InRange | { startHour: number, endHour: number } — hours of local day, wraps midnight | RuleEvaluator.ts |
channel.IsType | { channelType: string } — matches ctx.channel.type | RuleEvaluator.ts |
location.IsNamed | { name: string } — matches ctx.location.name | RuleEvaluator.ts |
user.IsInActiveCall | (none) — reads presentPlane.isInActiveCall | operators/userOperators.ts |
user.IsOnline | (none) — reads presentPlane.isOnline | operators/userOperators.ts |
device.IsConnected | { deviceId?: string } — reads presentPlane.activeDeviceIds | operators/userOperators.ts |
Asynchronous operators (query ContactTimelineService)
operator | params | Source |
|---|---|---|
comms.callCount | { contactId?, timeWindow="1d", operator="gt", value=0 } | operators/commsOperators.ts |
comms.lastContact | { contactId?, olderThanDays=7 } | operators/commsOperators.ts |
comms.hasVoicemail | { contactId? } | operators/commsOperators.ts |
comms.missedCalls | { contactId?, timeWindow="1d", operator="gt", value=0 } | operators/commsOperators.ts |
Async operators default contactId to ctx.person.id. The numeric operator field supports gt | gte | lt | lte | eq (default gt).
There is no
ai_classify,calendar.next_event_within_minutes,time.between,user.wifi_ssid,user.near,contact_in_group,person_exists,user.location_unknown, ortime.is_business_hoursoperator implemented today. Earlier drafts of this document listed those — they are aspirational and not present inRuleEvaluator.tsat commit46538a9.
Logical composition
Use AND / OR / NOT nodes (see AST shape). Composition is purely structural — there is no infix syntax.
Built-In Action Operators
Actions are a flat array of { type, params, delay? } objects. If delay is set (e.g. "30m", "2h", "1d") the action is handed to SchedulerService.scheduleAfter() instead of being executed inline.
Implemented in RulesService.executeAction():
type | params | Effect |
|---|---|---|
send.Sms | { channelId, to?, body } (defaults to to event.data.from) | ChannelService.sendMessage |
log.Audit | { ruleId? } | Writes a policy audit entry |
tag.Person | { tag } — requires ctx.person | Appends tag to persons.tags |
route.Call | { transferTo, callerId?, timeout?, whisperMessage? } | Emits <Dial> TwiML via TwimlService.dialForward |
route.Voicemail | { greetingText?, greetingUrl?, maxLength?, transcribe=true, voice? } | Emits voicemail TwiML |
route.IVR | { prompt, numDigits?, timeout?, voice? } | Emits <Gather> IVR menu TwiML |
route.Conference | { roomName, record?, waitUrl?, maxParticipants? } | Emits conference-join TwiML |
comms.sendSms | { contactId, body, channelId } | Looks up contact phone, sends via ChannelService |
comms.sendEmail | { contactId, subject, body } | Publishes email.SendRequested NATS event (picked up by MS Graph connector) |
comms.createFollowUp | { contactId, description, dueDate? } | Inserts row into tasks |
Unknown action types log a warning and are skipped. There is no built-in notify_user, forward_message, reject_call, ai_respond, ai_summarize, update_entity, add_tag_to_person, or generic enqueue_job action — earlier drafts of this document listed those and they are not implemented today.
Example Rule (JSON)
{
"id": "uuid",
"name": "route_family_calls_at_work",
"enabled": true,
"trigger_event_types": ["communication.CallStarted"],
"condition_ast": {
"type": "AND",
"children": [
{ "type": "OPERATOR", "operator": "person.InGroup", "params": { "group": "family" } },
{ "type": "OPERATOR", "operator": "location.IsNamed", "params": { "name": "Work" } },
{ "type": "OPERATOR", "operator": "time.InRange", "params": { "startHour": 9, "endHour": 17 } }
]
},
"actions": [
{
"type": "send.Sms",
"params": {
"channelId": "twilio-sms",
"to": "event.data.from",
"body": "I'm at the office. You can reach me at (727) 555-0100."
}
},
{ "type": "route.Voicemail", "params": { "transcribe": true } }
]
}
Event-Type Matching
trigger_event_types entries are matched by RulesService.matchesEventType:
- Exact match, or
- Suffix wildcard
domain.*matches any event whose type starts withdomain.(e.g.communication.*).
Delayed Actions
Any action may carry a delay field — e.g. { type: "send.Sms", params: {...}, delay: "30m" }. RulesService hands delayed actions to SchedulerService.scheduleAfter() with sourceType: 'rule'. parseDuration() accepts Ns, Nm, Nh, Nd suffixes.
Planned Extension Points
The following are aspirational — referenced by earlier design notes but not implemented today:
context.ruleEngine.registerConditionOperator(...)/registerActionOperator(...)— a plugin-facing API to extend the operator and action registries from inside a sandboxed plugin. No such API exists inapps/hub/src/services/rules/, no operator-registry module is wired up, and no plugin manifest references a rule-operator permission. Adding new operators currently requires editingRuleEvaluator.tsorRulesService.executeAction()directly and rebuilding the hub.
Related Services
RulesService— main entry point, event subscription, action execution, metrics, auditRuleEvaluator— sync + async AST walkersRuleAnalyticsService— execution counters and metricsRuleTemplateService— prebuilt rule templatesStreamProcessingService— populatespresentPlanestate from Redis before evaluationContactTimelineService— backs all asynccomms.*operatorsChannelService,TwimlService,SchedulerService,PolicyService,DataService,EventBus— action backends
File Map
| File | Purpose |
|---|---|
apps/hub/src/services/rules/types.ts | ConditionNode, RuleAction, EvaluationContext, PresentPlaneState |
apps/hub/src/services/rules/RuleEvaluator.ts | Sync + async evaluator with built-in operator switch |
apps/hub/src/services/rules/operators/userOperators.ts | user.IsInActiveCall, user.IsOnline, device.IsConnected |
apps/hub/src/services/rules/operators/commsOperators.ts | comms.callCount, comms.lastContact, comms.hasVoicemail, comms.missedCalls |
apps/hub/src/services/rules/RulesService.ts | Subscription, evaluation loop, action execution |
apps/hub/src/services/rules/RuleAnalyticsService.ts | Execution metrics |
apps/hub/src/services/rules/RuleTemplateService.ts | Templates |