Made Open

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 — RuleEvaluator walks the JSON AST against a fixed registry of operators and RulesService.executeAction() dispatches against a fixed registry of action types. This document describes the actual implemented surface in apps/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 (supports domain.* wildcards, see RulesService.matchesEventType)
  • condition_ast — a ConditionNode tree (see below)
  • actions — an array of RuleAction objects

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

operatorparamsSource
person.InGroup{ group: string } — matches ctx.person.tagsRuleEvaluator.ts
time.InRange{ startHour: number, endHour: number } — hours of local day, wraps midnightRuleEvaluator.ts
channel.IsType{ channelType: string } — matches ctx.channel.typeRuleEvaluator.ts
location.IsNamed{ name: string } — matches ctx.location.nameRuleEvaluator.ts
user.IsInActiveCall(none) — reads presentPlane.isInActiveCalloperators/userOperators.ts
user.IsOnline(none) — reads presentPlane.isOnlineoperators/userOperators.ts
device.IsConnected{ deviceId?: string } — reads presentPlane.activeDeviceIdsoperators/userOperators.ts

Asynchronous operators (query ContactTimelineService)

operatorparamsSource
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, or time.is_business_hours operator implemented today. Earlier drafts of this document listed those — they are aspirational and not present in RuleEvaluator.ts at commit 46538a9.

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():

typeparamsEffect
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.personAppends 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 with domain. (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 in apps/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 editing RuleEvaluator.ts or RulesService.executeAction() directly and rebuilding the hub.
  • RulesService — main entry point, event subscription, action execution, metrics, audit
  • RuleEvaluator — sync + async AST walkers
  • RuleAnalyticsService — execution counters and metrics
  • RuleTemplateService — prebuilt rule templates
  • StreamProcessingService — populates presentPlane state from Redis before evaluation
  • ContactTimelineService — backs all async comms.* operators
  • ChannelService, TwimlService, SchedulerService, PolicyService, DataService, EventBus — action backends

File Map

FilePurpose
apps/hub/src/services/rules/types.tsConditionNode, RuleAction, EvaluationContext, PresentPlaneState
apps/hub/src/services/rules/RuleEvaluator.tsSync + async evaluator with built-in operator switch
apps/hub/src/services/rules/operators/userOperators.tsuser.IsInActiveCall, user.IsOnline, device.IsConnected
apps/hub/src/services/rules/operators/commsOperators.tscomms.callCount, comms.lastContact, comms.hasVoicemail, comms.missedCalls
apps/hub/src/services/rules/RulesService.tsSubscription, evaluation loop, action execution
apps/hub/src/services/rules/RuleAnalyticsService.tsExecution metrics
apps/hub/src/services/rules/RuleTemplateService.tsTemplates