@lexq/cli 0.1.39 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -933,9 +933,27 @@ ${data.length} total`);
933
933
  ]
934
934
  }'
935
935
 
936
- Condition Operators:
937
- EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL,
938
- LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
936
+ Condition Operators (by fact type):
937
+ STRING EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
938
+ NUMBER EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL,
939
+ LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
940
+ BOOLEAN EQUALS, NOT_EQUALS
941
+ LIST_STRING HAS_ANY, HAS_ALL, HAS_NONE
942
+ LIST_NUMBER HAS_ANY, HAS_ALL, HAS_NONE
943
+
944
+ Using an operator outside its fact type is rejected by the server.
945
+
946
+ List-typed facts (HAS_*) — the value is always an array:
947
+ HAS_ANY fact has at least one of the given values
948
+ HAS_ALL fact has all of the given values
949
+ HAS_NONE fact has none of the given values
950
+
951
+ Example:
952
+ { "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY",
953
+ "value": ["VIP", "GOLD"], "valueType": "LIST_STRING" }
954
+
955
+ CONTAINS is substring match on STRING facts, not list membership.
956
+ IN is the mirror of HAS_*: scalar fact, list value.
939
957
 
940
958
  Action Types:
941
959
  MUTATE_FACT, INCREMENT_FACT, EMIT_EVENT, BLOCK, EMIT_NOTIFICATION, EMIT_WEBHOOK, SET_FACT, ADD_TAG
@@ -3712,8 +3730,23 @@ function registerRuleTools(server, callApi) {
3712
3730
  defined (non-blocking, version-wide) — use it to decide what to register.
3713
3731
 
3714
3732
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
3715
- Operators: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
3716
3733
  Value types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
3734
+
3735
+ Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
3736
+ its type is rejected by the server — check the fact type before choosing an operator.
3737
+ - STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
3738
+ - NUMBER fact: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
3739
+ - BOOLEAN fact: EQUALS, NOT_EQUALS
3740
+ - LIST_* fact: HAS_ANY, HAS_ALL, HAS_NONE (only these)
3741
+
3742
+ HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
3743
+ - HAS_ANY: fact has at least one of the given values
3744
+ - HAS_ALL: fact has all of the given values
3745
+ - HAS_NONE: fact has none of the given values
3746
+ Example: { "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
3747
+
3748
+ Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
3749
+ IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
3717
3750
 
3718
3751
  Actions: [{ type, parameters }]
3719
3752
 
@@ -3722,14 +3755,14 @@ function registerRuleTools(server, callApi) {
3722
3755
  - INCREMENT_FACT: { targetVar: string, method: "PERCENTAGE"|"AMOUNT", refVar?: string (required when PERCENTAGE), rate?: number (when PERCENTAGE), value?: number (when AMOUNT), rounding?: RoundingOption } targetVar (accumulation target) must exist at execution; refVar (PERCENTAGE source) must exist when method is PERCENTAGE. Each is supplied as an input fact or written by a prior action in this rule — a missing required fact throws (no 0 default). Note: external system call (e.g. point system sync) is NOT a primitive responsibility. Compose [INCREMENT_FACT, EMIT_EVENT] chain instead.
3723
3756
  - EMIT_EVENT: { integrationId: uuid, eventPayload: object (Map<string,unknown>, ≥1 entry) } eventPayload is passed through to the integration provider as-is. Domain-specific keys (couponId, ticketId, etc.) are routed by the provider, not validated by the engine.
3724
3757
  - BLOCK: { reason: string }
3725
- - EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token). notificationPayload (channel, templateId, body, variables, etc.) is passed through to the provider.
3758
+ - EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token) and is REQUIRED — the named fact must be present in the request or the action throws. (Contrast with ADD_TAG, where targetVar is an optional write target that is created if absent.)
3726
3759
  - EMIT_WEBHOOK: { url: string, method: "POST", payloadTemplate?: object } payloadTemplate is optional. Without it, all facts are sent as-is. With it, the object is sent as the HTTP body with {{variables}} replaced at execution time. Variables: {{fact.xxx}}, {{output.xxx}}, {{timestamp}}, {{ruleName}}, {{groupName}}, {{versionNo}}, {{xxx}} (shorthand).
3727
3760
  Platform examples:
3728
3761
  Slack: { "text": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
3729
3762
  Discord: { "content": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
3730
3763
  Generic: { "event": "rule_matched", "rule": "{{ruleName}}", "amount": "{{output.payment_amount}}" }
3731
3764
  - SET_FACT: { key: string, value: string|number|boolean }
3732
- - ADD_TAG: { tag: string, targetVar: string }
3765
+ - ADD_TAG: { tag: string, targetVar?: string (defaults to "user_tags") } Appends tag to a LIST_STRING fact, creating it if absent. Adding an existing tag is a no-op (idempotent). Read tags back with HAS_ANY / HAS_ALL / HAS_NONE.
3733
3766
 
3734
3767
  RoundingOption (optional, MUTATE_FACT / INCREMENT_FACT only): { scale: integer (0..16), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
3735
3768
  `,
@@ -323,8 +323,23 @@ function registerRuleTools(server, callApi) {
323
323
  defined (non-blocking, version-wide) — use it to decide what to register.
324
324
 
325
325
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
326
- Operators: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
327
326
  Value types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
327
+
328
+ Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
329
+ its type is rejected by the server — check the fact type before choosing an operator.
330
+ - STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
331
+ - NUMBER fact: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
332
+ - BOOLEAN fact: EQUALS, NOT_EQUALS
333
+ - LIST_* fact: HAS_ANY, HAS_ALL, HAS_NONE (only these)
334
+
335
+ HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
336
+ - HAS_ANY: fact has at least one of the given values
337
+ - HAS_ALL: fact has all of the given values
338
+ - HAS_NONE: fact has none of the given values
339
+ Example: { "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
340
+
341
+ Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
342
+ IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
328
343
 
329
344
  Actions: [{ type, parameters }]
330
345
 
@@ -333,14 +348,14 @@ function registerRuleTools(server, callApi) {
333
348
  - INCREMENT_FACT: { targetVar: string, method: "PERCENTAGE"|"AMOUNT", refVar?: string (required when PERCENTAGE), rate?: number (when PERCENTAGE), value?: number (when AMOUNT), rounding?: RoundingOption } targetVar (accumulation target) must exist at execution; refVar (PERCENTAGE source) must exist when method is PERCENTAGE. Each is supplied as an input fact or written by a prior action in this rule — a missing required fact throws (no 0 default). Note: external system call (e.g. point system sync) is NOT a primitive responsibility. Compose [INCREMENT_FACT, EMIT_EVENT] chain instead.
334
349
  - EMIT_EVENT: { integrationId: uuid, eventPayload: object (Map<string,unknown>, ≥1 entry) } eventPayload is passed through to the integration provider as-is. Domain-specific keys (couponId, ticketId, etc.) are routed by the provider, not validated by the engine.
335
350
  - BLOCK: { reason: string }
336
- - EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token). notificationPayload (channel, templateId, body, variables, etc.) is passed through to the provider.
351
+ - EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token) and is REQUIRED — the named fact must be present in the request or the action throws. (Contrast with ADD_TAG, where targetVar is an optional write target that is created if absent.)
337
352
  - EMIT_WEBHOOK: { url: string, method: "POST", payloadTemplate?: object } payloadTemplate is optional. Without it, all facts are sent as-is. With it, the object is sent as the HTTP body with {{variables}} replaced at execution time. Variables: {{fact.xxx}}, {{output.xxx}}, {{timestamp}}, {{ruleName}}, {{groupName}}, {{versionNo}}, {{xxx}} (shorthand).
338
353
  Platform examples:
339
354
  Slack: { "text": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
340
355
  Discord: { "content": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
341
356
  Generic: { "event": "rule_matched", "rule": "{{ruleName}}", "amount": "{{output.payment_amount}}" }
342
357
  - SET_FACT: { key: string, value: string|number|boolean }
343
- - ADD_TAG: { tag: string, targetVar: string }
358
+ - ADD_TAG: { tag: string, targetVar?: string (defaults to "user_tags") } Appends tag to a LIST_STRING fact, creating it if absent. Adding an existing tag is a no-op (idempotent). Read tags back with HAS_ANY / HAS_ALL / HAS_NONE.
344
359
 
345
360
  RoundingOption (optional, MUTATE_FACT / INCREMENT_FACT only): { scale: integer (0..16), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
346
361
  `,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "LexQ CLI — manage policies, simulate rules, and deploy from the terminal. Built for humans and AI agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -256,7 +256,7 @@ lexq rules create --group-id <gid> --version-id <vid> --json '{
256
256
  "type": "EMIT_NOTIFICATION",
257
257
  "parameters": {
258
258
  "integrationId": "<notification-integration-uuid>",
259
- "target": "user_id",
259
+ "targetVar": "user_id",
260
260
  "notificationPayload": {
261
261
  "channel": "PUSH",
262
262
  "templateId": "welcome_points"
@@ -502,4 +502,73 @@ lexq analytics simulation start --json '{
502
502
 
503
503
  # 6. If simulation passes, deploy
504
504
  lexq deploy live --group-id <gid> --version-id <newVid> --memo "Migration complete"
505
- ```
505
+ ```
506
+
507
+ ---
508
+
509
+ ## Recipe 11: Tag-Based Segmentation
510
+
511
+ **Goal:** Write tags in one rule, branch on them in another.
512
+
513
+ `user_tags` is a `LIST_STRING` fact seeded automatically for every tenant. `ADD_TAG` appends to
514
+ it; `HAS_ANY` / `HAS_ALL` / `HAS_NONE` read it.
515
+
516
+ ```bash
517
+ # Tags are written by earlier rules (or supplied as input facts).
518
+ lexq rules create --group-id <gid> --version-id <vid> --json '{
519
+ "name": "Tag High-Value Customers",
520
+ "condition": {
521
+ "type": "SINGLE", "field": "lifetime_value", "operator": "GREATER_THAN_OR_EQUAL",
522
+ "value": 1000000, "valueType": "NUMBER"
523
+ },
524
+ "actions": [
525
+ { "type": "ADD_TAG", "parameters": { "tag": "high_value" } }
526
+ ]
527
+ }'
528
+
529
+ # Branch on any one of several tags
530
+ lexq rules create --group-id <gid> --version-id <vid> --json '{
531
+ "name": "Priority Support for VIP or High Value",
532
+ "condition": {
533
+ "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY",
534
+ "value": ["VIP", "high_value"], "valueType": "LIST_STRING"
535
+ },
536
+ "actions": [{ "type": "SET_FACT", "parameters": { "key": "support_tier", "value": "PRIORITY" } }]
537
+ }'
538
+
539
+ # Require every tag
540
+ lexq rules create --group-id <gid> --version-id <vid> --json '{
541
+ "name": "Beta Feature for Verified VIP",
542
+ "condition": {
543
+ "type": "SINGLE", "field": "user_tags", "operator": "HAS_ALL",
544
+ "value": ["VIP", "verified"], "valueType": "LIST_STRING"
545
+ },
546
+ "actions": [{ "type": "SET_FACT", "parameters": { "key": "beta_enabled", "value": true } }]
547
+ }'
548
+
549
+ # Exclude tagged users
550
+ lexq rules create --group-id <gid> --version-id <vid> --json '{
551
+ "name": "Promo Excludes Fraud Review",
552
+ "condition": {
553
+ "type": "SINGLE", "field": "user_tags", "operator": "HAS_NONE",
554
+ "value": ["fraud_review", "suspended"], "valueType": "LIST_STRING"
555
+ },
556
+ "actions": [{ "type": "MUTATE_FACT", "parameters": {
557
+ "refVar": "payment_amount", "method": "PERCENTAGE", "operator": "SUB", "rate": 5,
558
+ "rounding": { "mode": "HALF_UP", "scale": 0 }
559
+ }}]
560
+ }'
561
+
562
+ # Verify
563
+ lexq analytics dry-run --version-id <vid> --debug --mock --json '{
564
+ "facts": { "user_tags": ["VIP", "verified"], "payment_amount": 100000, "lifetime_value": 500000 }
565
+ }'
566
+ ```
567
+
568
+ **Notes**
569
+
570
+ - The `value` is always an array, even for a single tag: `"value": ["VIP"]`.
571
+ - `ADD_TAG` writes to `user_tags` by default. Pass `targetVar` to append to a different `LIST_STRING` fact. The list is
572
+ created if absent, and re-adding an existing tag is a no-op.
573
+ - An empty array makes `HAS_ALL` and `HAS_NONE` always true — they never look at the fact.
574
+ - Rules fire in priority order, so a tag written by rule 0 is visible to rule 1.
@@ -70,17 +70,53 @@ Conditions use a tree structure with two node types: `SINGLE` and `GROUP`.
70
70
 
71
71
  ### Operators
72
72
 
73
- | Operator | Types | Description |
74
- |-------------------------|----------------|-----------------------------------|
75
- | `EQUALS` | all | Exact match |
76
- | `NOT_EQUALS` | all | Negation |
77
- | `GREATER_THAN` | NUMBER | `>` |
78
- | `GREATER_THAN_OR_EQUAL` | NUMBER | `>=` |
79
- | `LESS_THAN` | NUMBER | `<` |
80
- | `LESS_THAN_OR_EQUAL` | NUMBER | `<=` |
81
- | `CONTAINS` | STRING | Substring match |
82
- | `IN` | STRING, NUMBER | Value is in the provided list |
83
- | `NOT_IN` | STRING, NUMBER | Value is not in the provided list |
73
+ Operators are constrained by the **left fact's type**. Using one outside its type is rejected
74
+ by the server — check `lexq facts list` before choosing.
75
+
76
+ | Fact type | Allowed operators |
77
+ |-------------------------------|--------------------------------------------------------------------------------------------------------------------|
78
+ | `STRING` | `EQUALS`, `NOT_EQUALS`, `CONTAINS`, `IN`, `NOT_IN` |
79
+ | `NUMBER` | `EQUALS`, `NOT_EQUALS`, `GREATER_THAN`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN`, `LESS_THAN_OR_EQUAL`, `IN`, `NOT_IN` |
80
+ | `BOOLEAN` | `EQUALS`, `NOT_EQUALS` |
81
+ | `LIST_STRING` / `LIST_NUMBER` | `HAS_ANY`, `HAS_ALL`, `HAS_NONE` |
82
+
83
+ | Operator | Description | `value` |
84
+ |-------------------------------------------------------------------------------|------------------------------------------------------------|---------|
85
+ | `EQUALS` / `NOT_EQUALS` | Exact match / negation | scalar |
86
+ | `GREATER_THAN` / `GREATER_THAN_OR_EQUAL` / `LESS_THAN` / `LESS_THAN_OR_EQUAL` | Numeric comparison | scalar |
87
+ | `CONTAINS` | **Substring** match on a STRING fact — not list membership | scalar |
88
+ | `IN` / `NOT_IN` | Scalar fact is (not) in the given list | array |
89
+ | `HAS_ANY` | List fact has **at least one** of the given values | array |
90
+ | `HAS_ALL` | List fact has **all** of the given values | array |
91
+ | `HAS_NONE` | List fact has **none** of the given values | array |
92
+
93
+ **`IN` vs `HAS_*` — mirrors of each other.** This is the most common mistake here:
94
+
95
+ ```json
96
+ // scalar fact, list value
97
+ {
98
+ "field": "region",
99
+ "operator": "IN",
100
+ "value": [
101
+ "KR",
102
+ "JP"
103
+ ],
104
+ "valueType": "LIST_STRING"
105
+ }
106
+
107
+ // list fact, list value
108
+ {
109
+ "field": "user_tags",
110
+ "operator": "HAS_ANY",
111
+ "value": [
112
+ "VIP",
113
+ "GOLD"
114
+ ],
115
+ "valueType": "LIST_STRING"
116
+ }
117
+ ```
118
+
119
+ Do **not** use `CONTAINS` on a list fact — that idiom works in some rule engines but is rejected here.
84
120
 
85
121
  ### Value Types
86
122
 
@@ -145,10 +181,15 @@ Each rule can have multiple actions. Actions fire sequentially.
145
181
  | `INCREMENT_FACT` | Increment a fact (cumulative add) | `targetVar`, `refVar`, `method`, `value` or `rate`, `rounding` |
146
182
  | `EMIT_EVENT` | Emit an event to an external integration (coupons, etc.) | `integrationId`, `eventPayload` (Map) |
147
183
  | `BLOCK` | Block the transaction | `reason`, `code` |
148
- | `EMIT_NOTIFICATION` | Send a notification | `integrationId`, `target`, `notificationPayload` (Map) |
184
+ | `EMIT_NOTIFICATION` | Send a notification | `integrationId`, `targetVar`, `notificationPayload` (Map) |
149
185
  | `EMIT_WEBHOOK` | Call an external URL | `url`, `payloadTemplate` |
150
186
  | `SET_FACT` | Set a fact value (literal assignment) | `key`, `value` |
151
- | `ADD_TAG` | Add a tag to the result | `tag` |
187
+ | `ADD_TAG` | Append a tag to a list fact | `tag`, `targetVar` (optional, default `user_tags`) |
188
+
189
+ **Two different `targetVar` meanings.** `EMIT_NOTIFICATION.targetVar` is a **read** — it names the
190
+ fact holding the recipient (`phone_number`, `email`, `device_token`), and the action throws if that
191
+ fact is absent from the request. `ADD_TAG.targetVar` is a **write** — the list is created if absent,
192
+ and adding a tag that is already present is a no-op.
152
193
 
153
194
  ### Action Example: 10% Discount via MUTATE_FACT
154
195
 
@@ -305,4 +346,5 @@ Before creating rules, always:
305
346
  1. **Check available facts:** `lexq facts list`
306
347
  2. **Confirm the version is DRAFT:** `lexq versions get --group-id <gid> --id <vid>` → status must be `DRAFT`
307
348
  3. **Use exact fact keys** from the fact definitions (snake_case, case-sensitive)
308
- 4. **Match value types** — a fact defined as `NUMBER` must receive numeric values, not strings
349
+ 4. **Match value types** — a fact defined as `NUMBER` must receive numeric values, not strings
350
+ 5. **Match the operator to the fact type** — list-typed facts accept only `HAS_ANY` / `HAS_ALL` / `HAS_NONE`
@@ -111,7 +111,7 @@ lexq analytics dry-run --version-id <vid> --debug --mock --json '{
111
111
  "ruleId": "...",
112
112
  "ruleName": "VIP 10% Discount",
113
113
  "matched": true,
114
- "matchExpression": "(customer_tier == VIP AND payment_amount >= 100000)",
114
+ "matchExpression": "(customer_tier == 'VIP') && (payment_amount >= 100000)",
115
115
  "generatedActions": [
116
116
  {
117
117
  "type": "MUTATE_FACT",
@@ -143,26 +143,46 @@ lexq analytics dry-run --version-id <vid> --debug --mock --json '{
143
143
 
144
144
  ### Reading Decision Traces
145
145
 
146
- | Status | Meaning |
147
- |-----------------|---------------------------------------------|
148
- | `SELECTED` | Rule matched and its actions fired |
149
- | `NO_MATCH` | Condition did not match the input |
150
- | `NOT_SELECTED` | Matched but excluded by conflict resolution |
151
- | `BLOCKED_MUTEX` | Blocked by mutex group constraint |
152
- | `LOST_PRIORITY` | Lost to a higher-priority rule |
153
- | `DROPPED_LIMIT` | Execution limit reached |
154
- | `ERROR` | Rule evaluation failed |
146
+ Each trace carries a `status` (what happened) and a `reasonCode` (why).
147
+
148
+ | Status | Meaning |
149
+ |----------------|----------------------------------------------------|
150
+ | `SELECTED` | Rule matched and its actions fired |
151
+ | `NO_MATCH` | Condition did not match, or could not be evaluated |
152
+ | `NOT_SELECTED` | Matched but excluded by conflict resolution |
153
+ | `BLOCKED` | Blocked by a mutex group or group activation limit |
154
+ | `ERROR` | Action execution failed |
155
155
 
156
156
  ### Reading Reason Codes
157
157
 
158
- | Code | Meaning |
159
- |-----------------------|----------------------------------------------------------|
160
- | `FINAL_WINNER` | Successfully executed |
161
- | `CONDITION_MISMATCH` | Input facts didn't satisfy the condition |
162
- | `MUTEX_PRIORITY_LOST` | Another rule in the same mutex group had higher priority |
163
- | `MUTEX_LIMIT_REACHED` | Mutex group's max rules already fired |
164
- | `GROUP_LIMIT_REACHED` | Group's `executionLimit` reached |
165
- | `ACTION_ERROR` | Action execution failed (e.g., webhook timeout) |
158
+ | Code | Meaning |
159
+ |--------------------------|----------------------------------------------------------|
160
+ | `FINAL_WINNER` | Successfully executed |
161
+ | `CONDITION_MISMATCH` | Condition not satisfied, or could not be evaluated |
162
+ | `EFFECTIVE_DATE_INVALID` | Outside the version's effective date range |
163
+ | `MUTEX_PRIORITY_LOST` | Another rule in the same mutex group had higher priority |
164
+ | `MUTEX_LIMIT_REACHED` | Mutex group's max rules already fired |
165
+ | `GROUP_PRIORITY_LOST` | Another group in the same activation group won |
166
+ | `GROUP_LIMIT_REACHED` | Group's `executionLimit` reached |
167
+ | `ACTION_ERROR` | Action execution failed (e.g., webhook timeout) |
168
+ | `ENGINE_ERROR` | Internal engine failure |
169
+
170
+ #### `reasonDetail` on unevaluable conditions
171
+
172
+ `CONDITION_MISMATCH` covers two different things, distinguished by `reasonDetail`:
173
+
174
+ - **empty** — the condition was evaluated and did not match
175
+ - **`Evaluation error: <code>`** — the condition could not be evaluated at all
176
+
177
+ | Code | Meaning |
178
+ |-------------------------|--------------------------------------------------------------------|
179
+ | `FACT_NOT_PROVIDED` | The rule references a fact absent from the request |
180
+ | `FACT_TYPE_MISMATCH` | The fact's runtime type does not match the condition |
181
+ | `UNSUPPORTED_FACT_TYPE` | Operator not valid for the fact's type (e.g. `CONTAINS` on a list) |
182
+ | `MALFORMED_RULE` | The stored rule is structurally invalid |
183
+
184
+ A rule from another group referencing facts you did not send yields `FACT_NOT_PROVIDED` — this
185
+ is normal, not an error.
166
186
 
167
187
  ## 3. Dry Run Compare
168
188