Conditions
The field registry, the operators, how missing data behaves, and how every fact is recorded on the run.
"check": {
"type": "condition",
"params": {
"mode": "all",
"rules": [
{ "field": "contact.has_phone", "op": "is_true" },
{ "field": "history.last_task_outcome", "op": "is", "value": "voicemail" },
{ "field": "attrs.deal.stage", "op": "is_not", "value": "won" }
]
},
"next": { "yes": "sms_follow", "no": "email_2" }
}curl $SEQ/v1/schema/fields -H "Authorization: Bearer $KEY"
# {"sources":[{"prefix":"contact","label":"Contact","open":false,"fields":[{"field":"contact.country","label":"Contact country","type":"text"},…]},…],"attrs":[{"field":"attrs.deal.stage","label":"Deal stage","type":"enum","values":[…]}]}A condition is a list of { field, op, value } combined with all or any (1 to 20 rules). The registry of fields is closed and served at GET /v1/schema/fields, so your picker, the validator and the evaluator cannot disagree. The generated table of every field and operator is in the reference.
Fields
| Prefix | Fields | Source |
|---|---|---|
contact. | country, timezone (text), has_email, has_phone, has_linkedin, sms_consent (bool); also first_name, last_name, email are readable | the contact row |
attrs.<path> | anything you pushed, read as text | the contact's attrs |
sender. | timezone (text), has_sms, has_linkedin (bool); also name | the sender and its active channels |
history. | touches, days_since_last_touch, emails_sent, days_since_last_task, tasks_last_14d (number), replied_since_enrollment (bool), last_reply_channel (enum: email, sms, linkedin, call), last_task_outcome, last_task_kind (text) | the enrollment, runs, signals and tasks: one indexed query each |
linkedin.connection_state | enum: none, pending, connected, declined_or_ignored, withdrawn | reserved for LinkedIn automation |
crm. | person.exists, company.exists, deal.exists, deal.count_open and any person.<slug>, company.<slug>, deal.<slug> | one live Attio lookup per contact per step (CRM conditions) |
Operators
| Type | Operators |
|---|---|
| text | is, is_not, contains, is_empty, is_not_empty (case-insensitive) |
| enum | is, is_not (the value must be one of the field's values) |
| number | eq, ne, gt, gte, lt, lte |
| bool | is_true, is_false |
| date | before, after (ISO dates) |
The validator refuses an operator the field's type does not support (BAD_OPERATOR), a missing value where one is needed, a non-number for a number field, and a value outside an enum (BAD_VALUE). Fields the registry does not know are UNKNOWN_FIELD.
Missing data never matches
A fact that is null (the contact has no country, no task was ever completed, the CRM lookup found nothing or failed) fails every operator, including is_not and ne. So in all mode a missing fact sends the contact to no; in any mode it is simply not a match. Design for it: put the "has the data" rule first (contact.has_phone is_true), and treat no as the safe path.
Evaluation is lazy and recorded
Rules are read in order and the evaluation stops at the first decisive one (all stops at the first false, any at the first true). Each read is stored on the run as result.facts:
"facts": [
{ "field": "contact.has_phone", "label": "Contact has a phone", "value": true, "rule": "is true", "matched": true },
{ "field": "history.last_task_outcome", "label": "Last task outcome", "value": "connected", "rule": "is voicemail", "matched": false }
],
"mode": "all", "branch": "no", "note": "1 of 3 rules matched, so the contact took No."A read that failed (Attio down) also carries error. This is what the journey shows, so "why did they take No" is answered from what the decision saw.
Keeping facts fresh
attrs.* is what you last pushed; keep it current with PATCH /v1/contacts/{id}. history.* is maintained by the service. crm.* is read at decision time and needs no sync.