# Rules / Workflow Engine — Design Doc

**Doc ID:** RULES-SPEC
**Status:** Approved to build (v1 MVP)
**Owner:** Product / Engineering
**Last updated:** 2026-07-12

---

## 1. Problem

AI extracts obligations. Layer 3 pushes updates to source systems under a dual-control workflow. **What connects the two is currently code, not configuration.** Every "when X happens, do Y" behavior — auto-open a Jira on P1 SLA miss, downgrade Vitally health when a retention SLA breaches, block renewal when three consecutive obligations breach — requires a Python change today.

That's a hard ceiling on both product velocity and customer self-service. Every ops-tool competitor (Gainsight, Vitally, Zendesk, PagerDuty) ships a visible IF-THEN builder. We don't, and buyers above Business tier ask for it in every discovery.

**Goal:** ship a tenant-configurable IF-THEN rules engine that fires on obligation-state-change events and dispatches actions through existing Layer 3 infrastructure.

## 2. Non-goals (v1)

- **Not** a general workflow engine (no loops, no parallel branches, no sub-flows). One trigger → one condition tree → one action list.
- **Not** a scheduler. Time-based rules are v2.
- **Not** a full DSL / expression language. Conditions are structured JSON, evaluated by a whitelist of comparators.
- **Not** a customer-editable action list. Actions v1 are a fixed set of 4 (Slack, Email, Ticket, Health-score); customers pick + parametrize.
- **Not** predicate-on-arbitrary-fields. v1 predicates are limited to the obligation/breach/customer fields we already index.

## 3. Data model

Migration `0041_rules_engine.py`. Three new tables (all `TenantBase`).

### 3.1 `rules`
| column | type | notes |
|---|---|---|
| `id` | UUID PK | `gen_random_uuid()` |
| `tenant_id` | UUID | RLS-enforced |
| `name` | text | required |
| `description` | text | nullable |
| `enabled` | bool | default `true` |
| `trigger_event` | enum(`RuleTrigger`) | see 3.4 |
| `conditions` | jsonb | condition tree (see 3.5) |
| `actions` | jsonb | ordered list of action specs |
| `throttle_seconds` | int | dedupe within window per (rule, resource) — default 3600 |
| `created_by` | UUID FK users | audit trail |
| `created_at` / `updated_at` | timestamptz |

### 3.2 `rule_executions`
| column | type | notes |
|---|---|---|
| `id` | UUID PK | |
| `tenant_id` | UUID | RLS |
| `rule_id` | UUID FK rules | ON DELETE CASCADE |
| `trigger_event` | enum | denormalized for filtering |
| `resource_type` | text | e.g. `breach`, `obligation` |
| `resource_id` | UUID | resource that triggered the rule |
| `matched` | bool | did the condition tree return true |
| `condition_trace` | jsonb | per-node evaluation for debugging |
| `actions_attempted` | int | |
| `actions_succeeded` | int | |
| `action_results` | jsonb | array of per-action `{type, status, error?, payload_ref}` |
| `duration_ms` | int | wall-clock evaluator + action dispatch |
| `created_at` | timestamptz | |

### 3.3 `rule_action_throttle`
Deduplication cache. Simple key-value on `(tenant_id, rule_id, resource_id) → last_fired_at`. Backed by Postgres for durability (not Redis) so throttles survive restarts.

### 3.4 Trigger events (enum `RuleTrigger`)
```
breach.created          — new Breach row inserted
breach.status_changed   — breach status transitions (breach → mismatch, etc.)
breach.resolved         — breach set to compliant
commitment.mismatched   — SLA actual drifts vs contract
obligation.upcoming     — obligation within N days of due (fires from scheduler v2)
```
v1 ships `breach.created`, `breach.status_changed`, `commitment.mismatched`. The other two are wired but throw NotImplemented until v2.

### 3.5 Condition tree (JSON)
```json
{
  "op": "AND",
  "children": [
    { "field": "breach.severity",       "cmp": "in",  "value": ["high", "critical"] },
    { "field": "customer.plan",         "cmp": "eq",  "value": "enterprise" },
    { "field": "commitment.type",       "cmp": "eq",  "value": "sla_response_time" },
    { "op": "OR", "children": [
        { "field": "customer.arr",      "cmp": "gte", "value": 100000 },
        { "field": "customer.status",   "cmp": "eq",  "value": "at_risk" }
    ]}
  ]
}
```
- **Ops:** `AND`, `OR`, `NOT` (unary).
- **Comparators:** `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `contains`, `starts_with`, `is_null`, `is_not_null`.
- **Fields:** dot-path against a fixed context object (§4.2). Unknown fields evaluate to `unknown` (rule does not match).
- **Depth limit:** 8. Node count limit: 64. Reject at API layer.

### 3.6 Action spec (JSON, in `rules.actions`)
```json
[
  {
    "type": "ticket.open",
    "target": { "connector_id": "…", "connector_system": "jira" },
    "template": {
      "summary": "SLA breach on {customer.name}: {breach.commitment_name}",
      "description": "…",
      "priority": "high",
      "labels": ["zantiq-auto", "sla-breach"]
    }
  },
  { "type": "slack.notify", "target": {"channel": "#cs-escalations"}, "template": {"text": "…"} },
  { "type": "email.send",   "target": {"to": ["csm-oncall@acme.com"]}, "template": {"subject": "…", "body": "…"} },
  { "type": "health.set",   "target": {"connector_system": "vitally"}, "params": {"customer_key": "{customer.id}", "score_delta": -15} }
]
```
Templates use `{dot.path}` interpolation from the same context object. Missing values render as `<unknown>` and the interpolation itself never throws.

## 4. Evaluator

### 4.1 Trigger source
The breach detection Celery task at `api/app/tasks/breach.py::detect_breaches_task` is the primary trigger source in v1. After it creates a `Breach` row, it enqueues `run_rules_for_event(tenant_id, "breach.created", "breach", breach.id)`. Same pattern from `update_breach_status()` for status changes.

### 4.2 Context builder
`api/app/services/rules_context.py::build_context(db, tenant_id, resource_type, resource_id)` returns a nested dict:
```python
{
  "breach":     { "id", "severity", "commitment_name", "commitment_type", "created_at", ... },
  "commitment": { "type", "contract_value", "actual_value", ... },
  "contract":   { "id", "name", "value", "renewal_date", ... },
  "customer":   { "id", "name", "plan", "arr", "mrr", "status", "health_score", ... },
  "obligation": { ... }  # when applicable
}
```
Single query per resource. Cached per-invocation (won't refetch across rules in the same event burst).

### 4.3 Evaluator core
`api/app/services/rules_evaluator.py::evaluate(condition, context) -> (matched: bool, trace: dict)`. Pure function, no I/O, no exceptions on missing fields. Trace is a mirror of the tree with each node's evaluated value and result — this is what powers the debug view in the UI.

### 4.4 Dispatcher
`api/app/services/rules_dispatcher.py::dispatch(rule, context) -> list[ActionResult]`. Called by the Celery task after evaluator returns `matched=True`. Records a `RuleExecution` row in every path (matched or not) for observability.

### 4.5 Throttling
Before dispatch, check `rule_action_throttle` for `(rule_id, resource_id)`. If `last_fired_at` is within `throttle_seconds`, skip dispatch, record execution with `actions_attempted=0`, mark result as `throttled`. This prevents runaway loops if the same breach flips state repeatedly.

## 5. Action executors

All 4 v1 action types are thin wrappers over existing services:

| action | executor | reuses |
|---|---|---|
| `ticket.open` | `api/app/actions/ticket.py` | Layer 3 push_helper for Jira, Linear, Zendesk, Freshservice — new "create" path (existing push is "update") |
| `slack.notify` | `api/app/actions/slack.py` | existing Slack connector `push_data()` |
| `email.send` | `api/app/actions/email.py` | existing `services/email.py` transport |
| `health.set` | `api/app/actions/health.py` | Vitally + Gainsight `push_data()` |

Each executor:
1. Validates action spec against action-type JSON schema.
2. Interpolates template with context.
3. Calls the underlying service.
4. Returns `ActionResult(status: 'success' | 'failed' | 'skipped', error?, external_ref?)`.
5. Errors NEVER propagate — always captured into `action_results`.

## 6. REST API

Under `/rules` prefix, mounted centrally.

| method | path | notes |
|---|---|---|
| `GET`    | `/rules`                  | list, filterable by `enabled`, `trigger_event` |
| `POST`   | `/rules`                  | create; validates conditions + actions |
| `GET`    | `/rules/{id}`             | read |
| `PATCH`  | `/rules/{id}`             | update (any field) |
| `DELETE` | `/rules/{id}`             | soft-delete via `enabled=false`; hard-delete via `?hard=true` (admin only) |
| `POST`   | `/rules/{id}/test`        | dry-run against a synthetic or existing resource; returns condition trace + would-fire actions **without** dispatching |
| `POST`   | `/rules/{id}/simulate`    | dry-run against last N triggering events — coverage view |
| `GET`    | `/rules/{id}/executions`  | history, paginated, filterable |
| `GET`    | `/rules/schema`           | returns available fields, comparators, action types, connector targets — powers the UI builder |

All routes go through `get_current_tenant` + `get_tenant_db`, emit `log_event` on create/update/delete, and validate action targets against the tenant's connectors (`connector_id` must belong to caller's tenant + be enabled).

## 7. Frontend

New route `web/app/(app)/rules/`:
- `page.tsx` — list view, "New rule" button, enable/disable toggle per rule
- `[id]/page.tsx` — editor:
  - trigger picker (dropdown of `RuleTrigger` enum)
  - condition tree builder (nested AND/OR groups; comparator + value inputs typed against field schema from `/rules/schema`)
  - action list (add/remove/reorder; per-type form)
  - throttle slider (1min → 24h)
  - "Test with…" panel — pick a recent breach, see condition trace + would-fire actions
- `[id]/executions/page.tsx` — execution log with expandable trace + action-result table

Shared components: reuse `PageHeader`, `DataTable`, `TableSkeleton`, `EmptyState` from `web/components/shared/*`. Hooks: `web/lib/hooks/use-rules.ts` (TanStack Query).

## 8. Observability

- `RuleExecution` rows are the primary audit trail.
- Emit `log_event(ctx, "rule.executed", "rule", rule_id, ...)` on every execution.
- Structured log line per action: `logger.info("rule.action", rule=…, action_type=…, status=…, duration_ms=…)`.
- Admin analytics extended: rules-fired-per-day, action-success-rate, top-throttled rules.

## 9. Rollout

- **Migration 0041** — additive, no backfill needed.
- **Feature flag** `rules_engine_v1` on `Tenant.feature_flags` JSONB — off by default, on for design-partner tenants first.
- **Rate limit** — max 500 `rule_executions` per tenant per hour (soft cap; admin can bump). Prevents runaway.
- **Kill switch** — `RULES_ENGINE_ENABLED` env var, default `true`. If false, all evaluators short-circuit `matched=False`.

## 10. Success metrics

- **Adoption**: ≥30% of Business + Enterprise tenants create ≥1 rule within 30 days of GA.
- **Value**: median ≥3 rules per active tenant by day 60.
- **Reliability**: <0.1% action-executor error rate ex-connector-outages; <500ms p95 evaluator + dispatch.

## 11. Dependencies

- Existing: `field_mapping_push`, `push_helper`, `transform_dsl`, `audit`, breach Celery pipeline, connectors registry.
- New: `services/rules_context.py`, `services/rules_evaluator.py`, `services/rules_dispatcher.py`, `actions/*.py`.

## 12. Open questions (post-v1)

- **Time-based triggers** (renewal in N days, obligation approaching due date): v2. Requires a beat scheduler entry.
- **Rule ordering** (does rule A block rule B from firing?): v1 says no — rules are independent. If we need it, add `priority` int and a stop-on-match flag.
- **Multi-tenant rule templates** (curated starter rules a tenant can adopt): v2, once we know what customers write.
- **Approval workflow for rule creation** (dual-control like Layer 3 push): v1 no. Author + audit trail. Revisit if any auto-action can cause meaningful harm.

---

*End of RULES-SPEC.*
