# The Rule Engine, in Detail

> > Scope: how conditional logic is created, stored, processed, and — most importantly — > extended in the customization layer · Last reviewed: 2026-08-02

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/technical/rule_engine. Last updated 2026-08-25.

---
> **Scope:** how conditional logic ("rules") is created, stored, processed, and — most importantly —
> **extended in the customization layer** · **Last reviewed:** 2026-08-02

> **Naming:** this page uses the 4.0 (CB4) vocabulary — `QuestionProperty` conditions,
> `questionId`, `selectedAnswer.` field paths. CBX 3.x used the legacy element vocabulary
> (`ElementAttribute`, `elementId`, `selectedOption.`); the 4.0 migration rewrites stored rule
> JSON automatically — the full before/after map is in
> [`migration-to-cb4/element-question-rename.md`](https://docs.configbox.at/docs/migration-to-cb4/element-question-rename).

All paths are relative to `docroot/components/com_configbox/`.

---

## 0. Mental model

A **rule** decides a single boolean: *does this question/answer apply?* It is attached to a
**question** (`#__configbox_questions.rules`) or to an **answer** (`#__configbox_answers.rules`).

- For a **question**: rule true ⇒ show the question; with **negation** ⇒ hide it when true.
- For an **answer**: same, for an individual option.

A rule is a tree of **conditions** joined by **combinators** (`AND`/`OR`) and grouped by
**brackets**, optionally wrapped in a **negation**. Each condition is an instance of a
**condition type** (a PHP class). The engine turns the tree into a PHP boolean expression and
`eval()`s it. The *effects* (show/hide/grey-out/auto-select/require) are not part of the rule —
they're driven elsewhere by calling the rule and reacting to the boolean (see §4.5).

There are 4 built-in condition types (`classes/rule_condition_types/`):
`QuestionProperty`, `Calculation`, `CustomerGroup`, and the internal `Negation`. A fifth file,
`CustomConditionExample.php`, is the documented template for custom types.

---

## 1. Storage — what a rule actually is

A rule is a **JSON string** stored in a single text column:
- `#__configbox_questions.rules` — question-level rule (`models/questions.php:581`, property `type=>'rule'`).
- `#__configbox_answers.rules` — answer-level rule (`models/answers.php:508`).

The column is wired up as a Kenedo property of type **`rule`**
(`external/kenedo/properties/rule.php` → `KenedoPropertyRule`). That property class:
- renders the human-readable rule in list cells via `ConfigboxRulesHelper::getRuleHtml(...)`,
- re-maps IDs when a product is copied (`copyRule()`), and
- **validates the rule before it is stored** (`check()` → `getRuleErrors()`, §6).

The JSON itself is **produced entirely client-side** by the rule editor JS and written into a
hidden form field; the normal Kenedo model `store()` then persists that string verbatim. So
"saving a rule" is "saving a string column", with one gate in front of it: the property refuses a
rule the engine could not evaluate (§6). Nothing parses the rule *for meaning* on the way in — the
structure and the references are checked, the outcome is not.

### The JSON shape

A rule is a JSON **array of items**. Each item is one of:

| Item        | Shape                                                                 |
|-------------|-----------------------------------------------------------------------|
| condition   | `{ "type": "<TypeName>", "operator": "==", ...data }`                 |
| combinator  | `{ "type": "combinator", "kind": "AND" }` (or `"OR"`)                 |
| bracket     | a **nested array** of items (no `type` key)                          |
| negation    | `{ "type": "negation" }` — only ever the **first** item              |

The `...data` of a condition are arbitrary key/values the type defines (see §5). Example
(question shows only when answer 22 is chosen in question 7 **AND** the slider in question 8 is ≥ 5):

```json
[
  { "type": "QuestionProperty", "questionId": 7, "field": "selectedAnswer.id", "operator": "==", "value": "22" },
  { "type": "combinator", "kind": "AND" },
  [
    { "type": "QuestionProperty", "questionId": 8, "field": "selection", "operator": ">=", "value": 5 }
  ]
]
```

A negated version prepends `{ "type": "negation" }` to the array. An empty rule is `''` or `[]`
(meaning "always applies").

---

## 2. Creating a rule in the backend (the editor)

### 2.1 Where the editor lives
- Controller: `controllers/adminruleeditor.php` — a thin shell; its only job is to return the view.
- View: `views/adminruleeditor/view.html.php` (`ConfigboxViewAdminRuleeditor`) + `tmpl/default.php`.
- JS module: `assets/javascript/rule-editor.js` (loaded via `configbox/ruleEditor::initRuleEditor`,
  see `view.html.php:getJsInitCallsEach`).
- CSS: `assets/css/rule-editor.css`.

### 2.2 How it opens
On a question/answer edit form, the `rule` property renders (`properties/tmpl/rule.php`):
- a hidden `input.data-field` holding the current JSON, decorated with `data-editor-url`,
  `data-product-id`, `data-page-id`, `data-usage-in` (`question`|`answer`);
- a read-only HTML rendering of the current rule (`ConfigboxRulesHelper::getRuleHtml($rule, false)`);
- buttons: Change / Delete / Copy / Paste; and an empty `.rule-editor-modal`.

Clicking **Change** (handler on `.trigger-edit-rule`, `assets/javascript/properties/rule.js:91` —
the rule property's own JS module, no longer `admin.js`) loads the editor view (by
`data-editor-url`) into a Bootstrap modal and stores a back-reference to the originating form
property via `modal.data('form-property', ...)` (`rule.js:139`). The current rule JSON is passed
to the view as the `rule` request param.

### 2.3 How the editor builds its panels (server side)
`ConfigboxViewAdminRuleeditor::prepareTemplateVars()` (`view.html.php:96`):
1. Reads `rule`, `productId`, `pageId`, `usageIn` from the request.
2. Renders the current rule into editable HTML: `ConfigboxRulesHelper::getRuleHtml($rule)` (§3.4).
3. Picks the heading text by context ("Show the question if…" vs "Show the answer if…") and the
   negated variants.
4. Enumerates **all** condition types — `ConfigboxCondition::getConditionTypeNames()` (§5.1) —
   and for each whose `showPanel()` is true, builds a **tab title** (`getTypeTitle()`) and a
   **panel** of draggable available conditions (`getConditionsPanelHtml($this)`).

> Minor latent bug worth knowing: the intended tab ordering array uses plural names
> (`'Calculations'`, `'CustomerGroups'`) that don't match the real type names, and the
> `if ($key)` guard treats the index-0 type (`QuestionProperty`) as falsy. The net effect is the
> "preferred ordering" block is largely a no-op; tabs fall back to discovery order. Harmless, but
> surprising.

### 2.4 The editing surface (`tmpl/default.php`)
- A `.cb-rule-area` drop zone (the rule being built) — `tmpl/default.php:29`.
- `#combinator-blueprints`: draggable `AND` / `OR` spans
  (`<span class="item combinator" data-type="combinator" data-kind="AND">`).
- `#condition-picker`: one tab + panel per condition type; each panel lists draggable
  `<span class="item condition" …>` blueprints.
- A `#cb-editor-operator-picker-blueprint` (full = 6 operators, short = is/is-not,
  `tmpl/default.php:69`) shown when the user clicks a condition's operator.
- A negation `<select class="cb-rule-negated">` (Show-if vs Hide-if, `tmpl/default.php:23`).
- Buttons: Put-in-brackets, Remove-selected, Cancel, **Save**.

The editor's own DOM selectors carry a `cb-` prefix (`.cb-rule-area`, `.cb-rule-negated`,
`#cb-editor-operator-picker-blueprint`); the item/condition markup contract in §5.3 is unchanged.

The user drags conditions and combinators into `.cb-rule-area`, sets operators (click
`.condition-operator` → pick), types values into `.input` fields, and can group selected items
into brackets (`putInBrackets`, `rule-editor.js:560`).

### 2.5 Serializing the DOM back to JSON (`rule-editor.js`)
**Save** → `storeRule()` (`rule-editor.js:360`):
1. `getRuleItems(.cb-rule-area)` (`:440`) walks the DOM children with class `.item`:
   - `bracket` → recurse into children (becomes a nested array);
   - `function` (calc-term functions) → also collect `.parameter` sub-items;
   - everything else → `getItemMetadata(item)`.
2. `getItemMetadata(item)` (`:505`) builds the condition object by reading **every `data-*`
   attribute** of the span (jQuery `.data()` camel-cases them: `data-element-id` → `questionId`),
   skipping jQuery-UI internals, **plus** every child `.input` value keyed by its `data-data-key`
   (`:526`). Numeric inputs are normalized: the locale decimal symbol → `.` and cast to `Number`
   (`:540`).
3. If the negation select (`.cb-rule-negated`) is `1` and there are items, `{type:'negation'}` is
   unshifted to the front.
4. `JSON.stringify(ruleItems)` → written to the parent form's hidden `.data-field` (`:401`), and a
   read-only HTML version is copied back into the form. The modal closes.

The rule is now just a pending change in the question/answer form; pressing the form's own Save
persists `#__configbox_questions.rules` / `#__configbox_answers.rules` through the standard model
store.

---

## 3. Processing — how a rule is evaluated

Engine: `ConfigboxRulesHelper`, which ships **ionCube-encoded** (`helpers/encoded/`). The plaintext is
not part of the distribution, which is why this section documents the evaluation in the detail it does
— you cannot read it out of the tree.

### 3.1 Public entry point
```php
ConfigboxRulesHelper::ruleIsFollowed($jsonRule, $recordType, $recordId, $cartPositionId = NULL)
```
(`rules.php:62`)
- Empty rule (`''` / `'[]'`) ⇒ returns `true` (always applies).
- Loads the **current selections** for the configuration via
  `ConfigboxConfiguration::getInstance($cartPositionId)->getSelections()` (`:87`). These already
  include **simulated (sim) selections** transparently — critical for the consistency loop (§4.5).
- Delegates to `getEvaluationResult($jsonRule, $selections)`.

### 3.2 The core: `getEvaluationResult($jsonRule, $selections)` (`rules.php:106`)
1. Cache key = `serialize([$jsonRule, $selections])`; results memoized in `self::$resultCache`.
2. On first use it runs a **license check** (`checkLicense('rules')`, `:113`). (There's a
   deliberate comment about *not* using `self::` in spots to thwart wrapper-class license bypasses;
   the license path itself `@eval()`s server-returned code at `rules.php:1350` — a separate concern.)
3. `getConditions($jsonRule)` = `json_decode($jsonRule, true)`; a decode failure surfaces a system
   message and returns `false`.
4. `getConditionsCode($conditions, $selections)` (§3.3) builds a PHP boolean **string**.
5. `$evalCode = 'return ('.$ruleCode.');'` → `@eval($evalCode)` (`:139`), wrapped in a
   `ParseError` catch that logs the offending eval string and rule.

### 3.3 Building the code: `getConditionsCode($conditions, $selections)` (`rules.php:166`)
Walks the array and concatenates a string:
- If the first item is a `negation` ⇒ open with `!( ` and close with ` )` at the end.
- **bracket** (no `type`, or `type=='bracket'`) ⇒ recurse, wrapped in ` ( … ) `.
- **combinator** ⇒ append ` <kind> ` — i.e. the literal `AND`/`OR` (valid PHP operators) **injected
  verbatim**.
- **negation** item itself ⇒ contributes nothing (handled by the wrapper above).
- **anything else** (a real condition) ⇒
  `ConfigboxCondition::getCondition($type)->getEvaluationResult($condition, $selections)` and append
  the literal ` true ` or ` false `.

So for the §1 example the generated code is roughly:
```php
return (  true  AND  ( false )  );
```

> **Security nuance:** unlike the *calculation* engine (which injects operators and values into the
> eval string), the rules engine only evals the **boolean skeleton** — each condition is reduced to
> `true`/`false` in PHP first, and the comparisons (`version_compare`/`strcmp`) happen *inside* the
> condition classes, not in the eval. The only raw-injected tokens are the combinator `kind` and the
> bracket structure, both authored by an admin in the editor. It's still `eval`, but condition
> *values* are not an injection vector.

### 3.4 Rendering a rule as HTML
`getRuleHtml($jsonRule, $forEditing)` (`:380`) → `getConditionsHtml()` (`:416`) mirrors the eval
walk but emits HTML: brackets → `<span class="bracket item">…`, combinators → `getCombinatorMarkup`
(`:444`), conditions → `getCondition($type)->getConditionHtml($data, $forEditing)`. `$forEditing`
toggles editable `.input`s vs read-only `.condition-value` spans.

### 3.5 Caching & performance
- Per-request memoization keyed on `serialize([$jsonRule, $selections])`.
- Condition instances are singletons (`ConfigboxCondition::$instances`, `getCondition()` at
  `ConfigboxCondition.php:38`).
- **Hot path warning:** rules are evaluated on **every selection change**, once per
  question/answer, inside the consistency `do…while` loop — and `QuestionProperty` conditions call
  `ConfigboxPrices::getQuestionPrice()` / `ConfigboxQuestion::getQuestion()`. Custom types must cache
  aggressively (the example's `getEvaluationResult` docblock says exactly this).

---

## 4. The condition-type contract (`ConfigboxCondition`)

Every condition type is a subclass of the abstract `classes/ConfigboxCondition.php`.

### 4.1 Required (abstract) methods
| Method | Purpose |
|--------|---------|
| `getEvaluationResult($conditionData, $selections): bool` | The actual test. `$conditionData` is the decoded JSON item (camelCase keys from the editor); `$selections` is `[questionId => value]`. Return `true`/`false`. |
| `getConditionsPanelHtml($ruleEditorView): string` | The editor **tab panel** — a list of draggable available conditions for this type. |
| `getConditionHtml($conditionData, $forEditing = true): string` | The markup for one condition, both as an editor blueprint and as the saved/displayed form. **This HTML is the contract the JS serializer reads** (see §5.3). |

### 4.2 Overridable hooks (with sensible defaults)
- `getValidationErrors($conditionData): string[]` — what is wrong with this condition's data, checked
  when the question/answer is saved (§6). The default implementation checks the operator against
  `getOperators()`; override and call `parent` to add your own, or override without calling `parent`
  if your type has no operators. Runs at save time, so database lookups are fine here.
- `getOperators(): array` — default 6 relational operators (`<,<=,==,!=,>=,>`); override to restrict/rename.
- `getOperatorText($op)`, `getTypeName()`, `getTypeTitle()` — naming/labels (title shown on the tab).
- `showPanel(): bool` — return `false` to exist but not show an editor tab (as `Negation` does).
- `containsQuestionId / containsAnswerId / containsCalculationId($data, $id): bool` — let the app
  know the condition references an entity, so deletion of that entity can be blocked (used by
  `ruleContainsQuestion/Answer/Calculation`, `rules.php:210-380`).
- `getCopiedConditionData($conditionData, $copyIds): array` — re-map referenced IDs when a product
  is copied (e.g. `QuestionProperty` remaps `questionId`/`value` via the `$copyIds` map).

### 4.3 The built-ins (reference implementations)
- **`QuestionProperty`** (`...ConditionQuestionProperty.php`) — compares an attribute of another
  question: `selectedAnswer.id`, `selected`/`selection`, `price`, `priceRecurring`, and the
  `selectedAnswer.assignment_custom_1..4` / `option_custom_1..4` fields. Numeric values use
  `version_compare`, strings use `strcmp`, null handled for `==`/`!=`. Its panel is a dedicated
  Kenedo view — `ConfigboxViewAdminRuleeditor_questionproperty`
  (`views/adminruleeditor_questionproperty/view.html.php:4`; the pre-CB4 name
  `…_elementattribute` is gone).
- **`Calculation`** (`...ConditionCalculation.php`) — runs `ConfigboxCalculation::calculate($calcId, …, $selections)`
  and compares the result; remaps `calcId` on copy.
- **`CustomerGroup`** (`...ConditionCustomerGroup.php`) — compares a field of the current customer
  group (`ConfigboxUserHelper::getGroupData()`), e.g. a `custom_*` group field — note this is
  *independent of selections*.
- **`Negation`** (`...ConditionNegation.php`) — pseudo-type: `showPanel()=false`,
  `getEvaluationResult()` throws (it's never evaluated as a condition), added by the editor JS and
  interpreted structurally by `getConditionsCode`.

---

## 5. Extending the engine in the customization layer  ← the important part

The rule engine is designed to be extended **without touching shipped code or the encoded core**.
You add a new **condition type** as a single PHP file in the customization folder. It then appears
automatically as a tab in the rule editor and is evaluated like any built-in.

### 5.1 Discovery mechanism
`ConfigboxCondition` scans two directories (`ConfigboxCondition.php:74` and `:104`):
1. `…/com_configbox/classes/rule_condition_types` (shipped types)
2. `KenedoPlatform::p()->getDirCustomization().'/rule_condition_types'` (your custom types)

On Joomla, `getDirCustomization()` =
`…/components/com_configbox/data/customization` (`platforms/joomla/general.php:995`). So custom
condition files go in:

```
docroot/components/com_configbox/data/customization/rule_condition_types/
```

(`data/` is gitignored and survives upgrades — the intended place for customizations. Other parallel
extension dirs exist: `data/customization/templates`, `…/model_property_customization`,
`…/custom_observers`, etc.)

- `loadConditionClasses()` `include_once`s every `*.php` in both dirs.
- `getConditionClassNames()` / `getConditionTypeNames()` derive type names by stripping the
  `ConfigboxCondition` / `CustomCondition` prefix from the class/file name.
- `getCondition($type)` resolves the class by trying **`ConfigboxCondition<Type>` first, then
  `CustomCondition<Type>`** (`ConfigboxCondition.php:46-58`).

### 5.2 Naming rules (must follow exactly)
- File: `data/customization/rule_condition_types/CustomCondition<Type>.php`
- Class: `class CustomCondition<Type> extends ConfigboxCondition`
- The **`<Type>`** (e.g. `Inventory`) becomes the type name stored as `"type":"Inventory"` in the
  JSON and shown on the editor tab.
- **You cannot override a built-in** by reusing its type name: `getCondition()` finds
  `ConfigboxCondition<Type>` first, so `CustomConditionQuestionProperty` would be shadowed. Pick a
  new type name. (To truly replace a built-in you'd have to edit the shipped file — not
  upgrade-safe.)

### 5.3 What you implement
Copy `classes/rule_condition_types/CustomConditionExample.php` as your starting point. Implement:

1. **`getConditionHtml($conditionData, $forEditing)`** — emit the condition span. The **data
   contract** the serializer depends on:
   - Root element: `<span class="item condition" data-type="<Type>" data-operator="==" …>`.
   - Every `data-*` attribute becomes a JSON key (kebab → camelCase). `data-type` and `data-operator`
     are required.
   - For user-entered values, put `<input class="input" data-data-key="someKey" …>` **as a direct
     child** of `span.item.condition`. Its value lands in `$conditionData['someKey']`. **Use
     camelCase for `data-data-key`.** Numbers are auto-normalized for the locale decimal symbol.
   - Provide `.condition-name` (what the user sees) and `.condition-operator`
     (`$this->getOperatorText($op)`); the operator picker is wired automatically.
   - Respect `$forEditing`: editable `.input` when true, read-only `.condition-value` when false.

2. **`getConditionsPanelHtml($ruleEditorView)`** — return the editor tab's panel: a `<ul>` of
   `getConditionHtml($blueprint)` items the user drags into the rule. Each blueprint is an associative
   array with at least `type`, `operator`, and your data keys (see the example's `$availableConditions`).

3. **`getEvaluationResult($conditionData, $selections)`** — read your keys out of `$conditionData`,
   compute the truth value, return `bool`. `$selections` is `[questionId => value]`; for customer
   data use `ConfigboxUserHelper::getUser()`. **Cache** — this runs many times per selection change.

Optionally override `getTypeTitle()` (tab label), `getOperators()` (custom operator set),
`getValidationErrors()` (§6), `showPanel()`, the `contains*Id()` methods (so referenced entities
can't be deleted out from under the rule), and `getCopiedConditionData()` (ID remapping on product
copy).

### 5.4 Minimal example
`data/customization/rule_condition_types/CustomConditionStockLevel.php`:
```php
<?php
defined('CB_VALID_ENTRY') or die();

class CustomConditionStockLevel extends ConfigboxCondition {

    function getTypeTitle() { return KText::_('Stock level'); }

    // One draggable blueprint in the editor tab
    function getConditionsPanelHtml($ruleEditorView) {
        $blueprint = ['type'=>'StockLevel', 'sku'=>'', 'operator'=>'>=', 'threshold'=>''];
        return '<ul class="conditions-list"><li>'
             . $this->getConditionHtml($blueprint)
             . '</li></ul>';
    }

    function getConditionHtml($conditionData, $forEditing = true) {
        ob_start(); ?>
        <span class="item condition"
              data-type="<?php echo hsc($conditionData['type']); ?>"
              data-sku="<?php echo hsc($conditionData['sku']); ?>"
              data-operator="<?php echo hsc($conditionData['operator']); ?>">
            <span class="condition-name"><?php echo KText::_('Stock for SKU'); ?></span>
            <span class="condition-operator"><?php echo $this->getOperatorText($conditionData['operator']); ?></span>
            <?php if ($forEditing) { ?>
                <input class="input" data-data-key="sku"       type="text" value="<?php echo hsc($conditionData['sku']); ?>" />
                <input class="input" data-data-key="threshold" type="text" value="<?php echo hsc($conditionData['threshold'] ?? ''); ?>" />
            <?php } else { ?>
                <span class="condition-value"><?php echo hsc($conditionData['sku'].' '.($conditionData['threshold'] ?? '')); ?></span>
            <?php } ?>
        </span>
        <?php return ob_get_clean();
    }

    function getEvaluationResult($conditionData, $selections) {
        $stock = MyInventory::getStock($conditionData['sku']);   // your data source, cached
        return version_compare((float)$stock, (float)$conditionData['threshold'], $conditionData['operator']);
    }
}
```
Drop the file in, reload a question/answer edit form, open the rule editor — a **"Stock level"**
tab appears with a draggable condition; saving stores `{"type":"StockLevel","sku":"…","operator":">=","threshold":5}`
in the `rules` column, and the engine evaluates it on every selection change.

### 5.5 Gotchas when extending
- **Class loads twice safely** (`include_once`), but a fatal/parse error in your file breaks the
  whole admin — develop carefully.
- **Type name = class suffix**, and it must round-trip through the JSON; keep it alphanumeric.
- **Performance:** `getEvaluationResult` is on the per-keystroke hot path; memoize external lookups.
- **No effects in the condition** — a condition only returns true/false. To *do* something on a
  selection (auto-select, require, deselect), that's the configurator/consistency engine
  (`getInconsistencies` / `getAutoSelectItems`, `rules.php:632/1016`) plus the question's
  `behavior_on_activation` / `behavior_on_changes` / `behavior_on_inconsistency` settings — not the
  rule type.
- **i18n:** title via `getTypeTitle()`/`CONDITION_TYPE_<Name>` language key; UI strings via `KText::_`.
- **Other customization extension points follow the same pattern**: custom question types
  (`data/customization/classes/question_types` / `ConfigboxQuestion<Type>`), custom calc term types
  (`calc_term_types/`), and uploadable observers (admin Connectors) — the rule condition mechanism is
  one instance of a consistent "drop a prefixed class in `data/customization`" convention.

---

## 6. Validation on save

The editor is a drag and drop surface with **no validation of its own** (`storeRule()` serializes
whatever is in the drop zone), and the JSON is stored verbatim. What the engine then does with it is
build a PHP boolean expression and `eval()` it (§3.3), so a rule that does not hold together is not
a rule that behaves oddly — it is a `ParseError`, a thrown exception in the admin, or a question
that silently never shows.

`KenedoPropertyRule::check()` refuses those on the way in. It is the standard Kenedo property
validation hook, so it covers **every** write: the admin form (`KenedoController::store()`) and the
MCP authoring tools (`ConfigboxMcpHelper::storeData()`) both call `KenedoModel::validateData()`.
Failures come back as a 422, attributed to the `rules` field under `validationIssues` — `check()`
reports through the property's own error store, which `validateData()` turns into a
`KenedoValidationException` carrying one issue per bad field.

### 6.1 What is checked

**Structure** — each of these produces PHP that does not parse:

| Rule | Evaluated code |
|---|---|
| two conditions with no combinator | `return ( true  true );` |
| leading or trailing combinator | `return ( AND  true );` |
| two combinators in a row | `return ( true  AND  OR  false );` |
| an empty group | `return ( true  AND  () );` |
| nothing but a negation marker | `return (!(  ));` |

Also refused: a `negation` marker anywhere but the very first item of the whole rule (elsewhere it
contributes nothing, so the rule means something other than what was authored); an item that is
neither a condition, a group nor a combinator (the engine recurses into anything without a `type`
key and makes `()` of it); and a combinator whose `kind` is not `AND`/`OR` in any case — that value
is injected into the eval'ed string verbatim, so it is whitelisted rather than escaped.

**References and vocabulary** — these store and evaluate without complaint, which is why nobody
notices them:

- a condition `type` with no class behind it — `ConfigboxCondition::getCondition()` throws on it,
  and `getRuleHtml()` reaches the same call, so storing one breaks every list that shows the rule;
- an operator the type does not have — `getOperatorText()` throws the same way;
- a `field` that is not one of `getQuestionProperties()` (rules written before the CB4 rename say
  `selectedOption.id` and are in exactly this state);
- a question, answer or calculation that does not exist — a rule that can never be satisfied, and in
  the calculation's case a fatal error in the list view, since `getConditionHtml()` reads `->name`
  off whatever the cache hands back.

Per-type checks live in the condition classes (`getValidationErrors()`, §4.2), so a custom type gets
the structural and operator checks for free and can add its own.

### 6.2 What is deliberately *not* checked

- **A rule that is already stored.** Both write paths seed the request from the existing record, so
  an untouched rule is posted back on every partial update. Validating it would let a rule written by
  an older editor block edits to fields that have nothing to do with it. `check()` compares against
  the stored column first and only judges a rule that this save actually changes. The consequence:
  **broken rules already in the wild surface only when someone edits them** — finding them all is a
  separate audit, not this gate.
- **Whether the rule can ever be true.** A condition on a question of *another product* is
  well-formed and unsatisfiable; the MCP encoder rejects that on its own surface
  (`ConfigboxMcpRulesHelper`), the property does not.
- **Anything in the browser.** The editor still lets the user build a broken rule and closes its
  modal; the message arrives when the form is saved.

### 6.3 Two rules for anything added here

- **Never throw.** Validation reaches into condition classes, third-party ones included. Every call
  into one is wrapped — a fatal error during validation is worse than the broken rule it was meant
  to catch.
- **Never read through the cache.** `ConfigboxCacheHelper` is APCu-backed for the web process and
  file-backed on CLI, so an authoring tool would get a stale answer and refuse a rule that is
  perfectly valid on a freshly seeded catalog. The existence checks query the tables directly.

Covered by `tests/specs/backend/rule-validation.spec.ts`.
