> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Computed traits: author deterministic trait rules

> Stamp derived tags onto contacts with a deterministic, no-LLM rule DSL — operator-defined predicates over contact facts, preview-evaluated inline, maintained on a schedule.

# Computed traits

A **computed trait** manufactures a new fact and stamps it onto a contact. You author a predicate over contact facts — LTV, last seen, lifecycle stage, opt-in flags — and every contact that matches gets the rule's `output_tag` applied to their profile. That tag then behaves like any other attribute: segmentable, personalizable, exportable.

The [CDP audiences guide](/guides/cdp-segments) covers the API in brief. This page covers the dedicated **Audience → Computed traits** screen in the dashboard, the rule DSL in full, and the day-to-day operations behind it.

## Deterministic rules, not AI suggestions

The platform carries three ways to derive traits. Pick deliberately, because the behavior differs:

* **Computed traits (this page)** — a deterministic JSON predicate you author. Evaluation is a pure function: the same facts and the same predicate produce the same verdict every time. There is no LLM call in the evaluation path, so running a rule costs nothing per-contact.
* **AI traits** — an LLM fills a fixed set of score columns (churn, LTV, lifecycle, next-best-action). Useful when the signal can not be expressed as a comparison against recorded facts.
* **Tag autopilot** — an LLM proposes open-ended tag chips from free form language. A discovery surface, not a targeting primitive.

Reach for AI-derived surfaces when you genuinely need model judgment across unstructured text. When the rule can be written as a predicate — "LTV over 500 and seen this month" — author it here deterministically: cheap to run, reproducible in previews, and auditable in reviews, because the whole rule is right there in JSON.

## Permissions

The Audience → Computed traits screen is visible to **owner, admin, and developer** roles. A member with any other role (analyst, marketer, user) sees a gate message instead of the list. The same boundary exists on the API — list, evaluate, and preview calls accept owner/admin/developer/analyst/marketer, but create, update, delete, and run require owner/admin/developer. The screen-level gate is a courtesy so members without write access ever see an empty, non-functional list.

## The screen's five states, and why they matter

The page is an exemplar of covering every state a list can be in, which is what makes it usable after you've authored more than a handful of rules:

* **Loading** — skeleton rows match the table density, so the layout does not jump once data lands.
* **Empty** — a quiet inline message with a Create your first trait button — not an unbroken table.
* **Error** — an inline alert with a Retry button bound to re-fetch; a transient failure does not strand you.
* **Permission gate** — covered above.
* **No matches** — the toolbar search filters by name, description, and output tag; when a search excludes everything, a filtered empty state offers to clear the query instead of implying you have no traits at all.

Two further details keep the list smooth at scale: rows paginate through a cumulative "Load more" control, so a large rule set never mounts hundreds of table rows, and a failed delete surfaces the server error as a toast — the row stays in place so you can retry.

## Author a rule in the dashboard

Open **Audience → Computed traits** and press **Create trait**. The editor sheet has two halves. On the left you define the rule itself:

* **Name** and optional **Description** — the WHY behind the rule for the next operator.
* **Output tag** — lowercase kebab-case, 1–64 characters (for example `high-value-active`). This is the identifier stamped onto matching contacts, so it must be unique across your tenant.
* **Predicate** — the rule's JSON body. A starter predicate is pre-filled so you are editing a working baseline, not staring at a blank area. The DSL accepts nested `all_of` / `any_of` / `not` combinators over leaf comparisons; the editor shows the exact leaf shape inline.
* **Enabled** — a disabled rule persists but no longer evaluates; tags it already stamped stay in place.

On the right, in edit mode, the **Test against facts** panel runs the saved rule against a contact fact bag you paste inline. The verdict (Matches / Does not match) comes back without touching any contact — use it to answer "would this customer match?" before you turn the rule on, or to iterate on the predicate against synthetic facts.

## The DSL vocabulary

A predicate tree is made of leaf comparisons nested under combinators. Depth is capped at 8 levels and total leaves at 32, which keeps worst-case evaluation a known constant.

| Leaf kind | Operators                                            | Value shape                                                                                                |
| --------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `numeric` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between`     | a number, or a `[low, high]` pair for `between` (inclusive)                                                |
| `string`  | `eq`, `neq`, `contains`, `starts_with`, `in`         | a string, or an array of strings for `in`; `contains` is case-insensitive                                  |
| `boolean` | `eq`, `neq`                                          | `true` / `false`                                                                                           |
| `date`    | `within_days`, `older_than_days`, `between_days_ago` | a day count, or a pair for `between_days_ago` — relative windows, so "last 7 days" needs no timestamp math |
| `exists`  | `exists`, `not_exists`                               | presence check on any fact type                                                                            |

A leaf looks like this:

```json theme={null}
{ "kind": "numeric", "trait": "predicted_ltv", "op": "gt", "value": 500 }
```

Combine leaves with `all_of` (AND), `any_of` (OR), and `not`:

```json theme={null}
{
  "all_of": [
    { "kind": "numeric", "trait": "predicted_ltv", "op": "gt", "value": 500 },
    { "kind": "date", "trait": "last_seen_at", "op": "within_days", "value": 7 },
    { "not": { "kind": "boolean", "trait": "sms_optin", "op": "eq", "value": false } }
  ]
}
```

Two rules that keep the DSL safe:

* **Missing facts never match.** A fact that is absent or the wrong type evaluates every leaf to `false` — except a deliberate `exists` / `not_exists` check. Absence cannot creep into your audience by accident.
* **Versioned DSL.** The predicate shape is pinned to a DSL version. Saved rules validate against it at read time, so an old rule is never silently misread by an upgraded evaluator.

The platform supplies common contact facts — `predicted_ltv`, `churn_risk_score`, `lifecycle_stage`, `engagement_score`, `last_seen_at`, `created_at`, `total_revenue`, `email_optin`, `sms_optin` — and any [custom field](/guides/custom-fields) key is fair game; the vocabulary is open.

## The API surface

Everything the screen does, the API does. Owner/admin/developer for writes; the read plus evaluate endpoints accept analyst and marketer as well.

```bash theme={null}
# Create
curl -X POST https://api.orbit.devotel.io/api/v1/cdp/computed-traits \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "High-value active",
    "description": "LTV over 500 and active in the last week",
    "output_tag": "high-value-active",
    "predicate": {
      "all_of": [
        { "kind": "numeric", "trait": "predicted_ltv", "op": "gt", "value": 500 },
        { "kind": "date", "trait": "last_seen_at", "op": "within_days", "value": 7 }
      ]
    },
    "enabled": true
  }'
```

| Operation                              | Endpoint                                        |
| -------------------------------------- | ----------------------------------------------- |
| List rules (up to 200, newest first)   | `GET /api/v1/cdp/computed-traits`               |
| Create a rule                          | `POST /api/v1/cdp/computed-traits`              |
| Partially update a rule                | `PUT /api/v1/cdp/computed-traits/:id`           |
| Delete a rule                          | `DELETE /api/v1/cdp/computed-traits/:id`        |
| Preview against caller-supplied facts  | `POST /api/v1/cdp/computed-traits/:id/evaluate` |
| Apply the tag to matching contacts now | `POST /api/v1/cdp/computed-traits/:id/run`      |

Updates are partial — send only the fields you are changing, and every field is individually optional. Property edits are idempotent by nature: repeating the same payload leaves the rule in the same state. A duplicate `output_tag` comes back 409; a malformed predicate is 422 with one error message per issue; a predicate that exceeds the depth or leaf budget is 400 with the reason inline.

The preview endpoint takes a flat fact bag in the body and returns the boolean verdict without writing anything:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/cdp/computed-traits/ctr_01J8Z9K3P4Q5R6S7T8U9V0W1X2/evaluate \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "facts": { "predicted_ltv": 620, "last_seen_at": "2026-08-15T10:00:00Z" } }'
```

The dashboard's Test against facts panel uses this same endpoint, so whatever verdict you see in the UI is reproduced exactly by the API call.

## How assignments stay in sync

Creating a rule does not stamp it immediately. Two paths move tags onto contacts:

* A **scheduled evaluator** sweeps enabled rules periodically and reconciles each assignment set, adding the tag to new matches and removing it from contacts that no longer match — so membership tracks the current data, not the data at first run.
* **Run now** (the row action in the dashboard, or `POST .../:id/run`) applies a rule on demand, with the same add-and-remove reconciliation but bounded per run. The response reports `evaluated`, `matched`, `tag_added`, `tag_removed`, and a `capped` flag that tells you the run hit its per-run ceiling for large tenants. When `capped` is true the response tells you some contacts were not reached — re-run to continue the sweep.

Deleting a rule stops future evaluation; tags already on contacts stay in place. Every create, update, delete, and run is recorded in the [audit log](/guides/audit-log), so a marketing team's rule history is answerable to "who changed this predicate and when."

## Where the stamped tags are used

Once a rule materializes, its `output_tag` is just another attribute. Segments filter on it (`tag` equals `high-value-active`), journeys [branch](/guides/segment-triggered-journeys) on it, exports include it, and personalization slots read it. That is the point of the whole surface: a deterministic way to upconvert your raw facts into one stable, addressable, budget-friendly label — and to do it with the same rigor as the segment definitions in the [CDP audiences guide](/guides/cdp-segments).
