Skip to main content
Version: 3.x

The Rule Engine, in Detail

Version
3.x
Updated
View markdown

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

Naming: this page uses the 4.0 (CB4) vocabulary — QuestionProperty conditions, questionId, and the field keys a question type declares (selected, price, w, …). 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. Migration 3.8.26 then unified the field names (selectedAnswer.id / selection became selected); see §1.

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 (the one condition about a question, generic over every question type through the fields the type declares, §4.3), Calculation, CustomerGroup, and the internal Negation. The documented template for a custom type lives in docs/customization/examples/CustomConditionExample.php; it no longer ships in classes/, where it rendered as a live "Custom Conditions" entry in every install.


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:

ItemShape
condition{ "type": "<TypeName>", "operator": "==", ...data }
combinator{ "type": "combinator", "kind": "AND" } (or "OR")
bracketa 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):

[
{ "type": "QuestionProperty", "questionId": 7, "field": "selected", "operator": "==", "value": "22" },
{ "type": "combinator", "kind": "AND" },
[
{ "type": "QuestionProperty", "questionId": 8, "field": "selected", "operator": ">=", "value": 5 }
]
]

The same field key names the selection on both questions; what it means (an answer id on question 7, a number on the slider 8) is decided by the question's type, not by the condition (§4.3). A field declared once per answer carries answerId too: { "type": "QuestionProperty", "questionId": 42, "field": "quantity", "answerId": 57, "operator": ">=", "value": "3" }.

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

One vocabulary: migration 3.8.26​

Rules used to name the selection three ways (selectedAnswer.id, selection, selected), formula terms named the field fieldPath where rules said field, and pre-3.6 data nested the keys under a data wrapper. Since the question types declare their fields once, the stored data was brought onto that one vocabulary: helpers/updates/3.8.26.php rewrites every question rule, answer rule and formula through ConfigboxRulesJsonHelper::normalize() (helpers/rulesjson.php, the one walker over a rule or formula tree, brackets and function parameters included). selectedAnswer.id / selection become selected, fieldPath becomes field, questionId becomes an int, the data wrapper is flattened. Idempotent; a value that is not valid JSON is logged and left alone. The transfer import normalizes older packages the same way. The type name QuestionProperty is unchanged: it is persisted data and the calc-code DSL's function name.


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/editor-picker.css (the question-first picker, shared with the formula editor) + 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 picker (server side)​

ConfigboxViewAdminRuleeditor::prepareTemplateVars() (view.html.php:125):

  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. prepareQuestions() (:166): loads the product's questions in page and question order (ConfigboxRulesHelper::getQuestions($productId)) and, for each one the cache knows, asks the generic question condition for the chips that question offers (ConfigboxConditionQuestionProperty::getQuestionConditionsHtml(), §4.3): one group per field group the question's type declares. A product with more than ten questions also gets a page filter and a title filter.
  5. prepareOtherConditions() (:214): enumerates the condition types (ConfigboxCondition::getConditionTypeNames(), §5.1) in a fixed order for the stock ones (Calculation, CustomerGroup) and then whatever the site added, skips a type whose showPanel() is false or whose panel is empty, and keeps title (getTypeTitle()) and panel (getConditionsPanelHtml($this)) per type. These become the entries under "Other conditions".

The earlier note about a latent "tab ordering" bug is obsolete: there are no tabs, and the order of the non-question entries is the explicit list in prepareOtherConditions().

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 (tmpl/default.php:44), the question-first picker shared with the formula editor (assets/css/editor-picker.css): on the left a .cb-editor-question-list with one <li> per question (data-group="question-<id>"; the page filter and #question-filter above it when the product is long), then the "Other conditions" .cb-editor-other-list with one <li> per non-question type (data-group="type-<Type>"). On the right .cb-editor-groups, one .cb-editor-group per question and per type (#cb-editor-group-question-<id>, #cb-editor-group-type-<Type>), each holding the draggable <span class="item condition" …> chips; clicking an entry on the left shows its group and hides the others (rule-editor.js:325).
  • A #cb-editor-operator-picker-blueprint (full = 6 operators, short = is/is-not, tmpl/default.php:99) shown when the user clicks a condition's operator; a chip carrying cb-rule-condition--short-operators (a choice or a text field) gets the short one (rule-editor.js:235).
  • A negation <select class="cb-rule-negated"> (Show-if vs Hide-if, tmpl/default.php:23).
  • Buttons: Put-in-brackets, Remove-selected, Limit-condition-width, 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 what the serializer reads.

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

2.5 Serializing the DOM back to JSON (rule-editor.js)​

Save → storeRule() (rule-editor.js:344):

  1. getRuleItems(.cb-rule-area) (:424) 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) (:489) builds the condition object by reading every data-* attribute of the span (jQuery .data() camel-cases them: data-question-id → questionId, data-field → field, data-answer-id → answerId), skipping jQuery-UI internals, plus every child .input value keyed by its data-data-key. Numeric inputs are normalized: the locale decimal symbol → . and cast to Number. There is no per-type code in the serializer; the chip markup IS the stored shape.
  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, 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​

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:

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 .inputs 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 ConfigboxQuestion::getQuestion() and read the field through getFieldValue(), which for the price fields reaches ConfigboxPrices. Custom types must cache aggressively (the example's getStock() comment says exactly this), and so must a custom question type's getFieldValue().

4. The condition-type contract (ConfigboxCondition)​

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

4.1 Required (abstract) methods​

MethodPurpose
getEvaluationResult($conditionData, $selections): boolThe actual test. $conditionData is the decoded JSON item (camelCase keys from the editor); $selections is [questionId => value]. Return true/false.
getConditionsPanelHtml($ruleEditorView): stringThe type's panel in the editor's picker: a <ul class="cb-rule-conditions-list"> of draggable available conditions, shown when the type's entry under "Other conditions" is clicked.
getConditionHtml($conditionData, $forEditing = true): stringThe 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 (the title is the type's entry under "Other conditions").
  • showPanel(): bool — return false to exist but get no entry in the picker (as Negation does, and as QuestionProperty does: its chips are listed per question instead).
  • 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, answerId and, for the selected field of an answer-taking question, value via the $copyIds map).

4.3 The built-ins (reference implementations)​

  • QuestionProperty (...ConditionQuestionProperty.php) — compares a field of a question with a value, and is generic over question types: it knows nothing about them. What a question offers is declared by its class (ConfigboxQuestion::getRuleFields()), how a value is read is getFieldValue(), how it compares is ConfigboxQuestionField::matches(); this class only wires the declaration to the engine. Stored shape: {"type":"QuestionProperty","questionId":42,"field":"selected","operator":"==","value":"57"}, plus answerId for a field declared once per answer ({…,"field":"quantity","answerId":57,…}). Base fields every type has: selected (the answer for a question with answers, the entry otherwise), price, priceRecurring, weight, question_custom_1..4 and, with answers, selectedAnswer.assignment_custom_1..4 / selectedAnswer.option_custom_1..4; a type adds its own (w, area, quantity, …). Comparison is per field kind: numbers numerically (version_compare is gone; 2.5 >= 2.10 is true), dates by day, choices and texts as strings with ==/!= only; an empty condition value means "not answered", and an unanswered question is null, never 0. Every method that reads a stored condition runs it through ConfigboxRulesJsonHelper::normalizeItem() first (normalized()), so a condition still saying selectedAnswer.id or selection — from a table no migration knows, or a writer that still emits the old names — evaluates as selected. It has no panel (showPanel() false, :37): the editor lists its chips under each question via getQuestionConditionsHtml() (:174), grouped by the field's group. getValidationErrors() (:235) refuses a missing question, a field the type does not declare, an operator the field cannot take, a value of the wrong kind, and an answer that is not the question's. remapReferences() (:379) remaps questionId, answerId and the value of selected on an answer-taking question, for product copy and transfer alike. The per-type sub-view views/adminruleeditor_questionproperty/ is gone. The runtime API's explainQuestion projection labels blockers through the same field declaration (ConfigboxConfiguratorApiHelper::projectCondition(), helpers/configuratorapi.php:1345: fieldTitle, valueLabel, currentValueLabel).
  • 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, and the extension point depends on what the condition is about:

  • A condition about a question (its answer, its entry, a part of a composite selection such as the width of a dimensions question) is not a condition type. The question type declares it as a field in getRuleFields() and reads it in getFieldValue(); the generic QuestionProperty condition (§4.3) does the chips, the evaluation, the validation, the delete guards and the id remapping. See customization/com_configbox_custom_question_types.md, §2.6 "Rules and formulas — the type declares its fields". Nothing in this section is needed for it.
  • A condition about anything else (a stock level, the weekday, external data) is a condition type: a single PHP file in the customization folder. It then appears automatically as an entry under "Other conditions" in the rule editor and is evaluated like any built-in. That is what the rest of §5 covers; the full guide is customization/com_configbox_custom_rule_conditions.md.

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_onces 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; the entry under "Other conditions" shows getTypeTitle().
  • 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 docs/customization/examples/CustomConditionExample.php (a stock-level condition, class CustomConditionStockLevel) 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), so every data-* attribute on the chip is a stored key. data-type and data-operator are required.
    • For user-entered values, put <input class="input" data-data-key="someKey" …> inside 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 .cb-rule-condition-name (what the user sees) and .cb-rule-condition-operator ($this->getOperatorText($op)); the operator picker binds to the operator span automatically.
    • Respect $forEditing: editable .input when true, read-only .cb-rule-condition-value when false.
  2. getConditionsPanelHtml($ruleEditorView) — return the type's panel: a <ul class="cb-rule-conditions-list"> of <li> wrapping getConditionHtml($blueprint) items the user drags into the rule. It is shown when the type's entry under "Other conditions" is clicked. Each blueprint is an associative array with at least type, operator, and your data keys (the example builds one per SKU).

  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() (the entry's 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 (the shipped example is the same idea with comments and validation):

<?php
defined('CB_VALID_ENTRY') or die();

class CustomConditionStockLevel extends ConfigboxCondition {

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

// The panel under "Other conditions": one draggable blueprint
function getConditionsPanelHtml($ruleEditorView) {
$blueprint = ['type'=>'StockLevel', 'sku'=>'ABC-100', 'operator'=>'>=', 'value'=>''];
return '<ul class="cb-rule-conditions-list"><li>'
. $this->getConditionHtml($blueprint)
. '</li></ul>';
}

function getConditionHtml($conditionData, $forEditing = true) {
ob_start(); ?>
<span class="item condition stocklevel"
data-type="StockLevel"
data-sku="<?php echo hsc($conditionData['sku']); ?>"
data-operator="<?php echo hsc($conditionData['operator']); ?>">
<span class="cb-rule-condition-name"><?php echo hsc(KText::sprintf('Stock of %s', $conditionData['sku'])); ?></span>
<span class="cb-rule-condition-operator"><?php echo $this->getOperatorText($conditionData['operator']); ?></span>
<?php if ($forEditing) { ?>
<input class="input" data-data-key="value" type="text" value="<?php echo hsc($conditionData['value'] ?? ''); ?>" />
<?php } else { ?>
<span class="cb-rule-condition-value"><?php echo hsc($conditionData['value'] ?? ''); ?></span>
<?php } ?>
</span>
<?php return ob_get_clean();
}

function getEvaluationResult($conditionData, $selections) {
$stock = MyInventory::getStock($conditionData['sku']); // your data source, cached
return ConfigboxQuestionField::compare((float) $stock, (float) $conditionData['value'], $conditionData['operator']);
}
}

Drop the file in, reload a question/answer edit form, open the rule editor: a "Stock level" entry appears under "Other conditions" with a draggable condition; saving stores {"type":"StockLevel","sku":"ABC-100","operator":">=","value":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::_.
  • A condition about a question does not belong here. Declare a field on the question type (data/customization/question_types/ConfigboxQuestion<Type>.php, getRuleFields()). The old pattern of a per-type condition class reading a subValue is gone; the core migration 3.8.26 unifies the QuestionProperty vocabulary, and a site that had such classes rewrites their stored conditions to QuestionProperty with a field in its own migration track.
  • Other customization extension points follow the same pattern: custom question types (data/customization/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:

RuleEvaluated code
two conditions with no combinatorreturn ( true true );
leading or trailing combinatorreturn ( AND true );
two combinators in a rowreturn ( true AND OR false );
an empty groupreturn ( true AND () );
nothing but a negation markerreturn (!( ));

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 the question's type does not declare (findRuleField()), an operator the field's kind cannot take, or a value of the wrong kind (rules written before the CB4 rename say selectedOption.id and are in exactly this state; the pre-3.8.26 spellings selectedAnswer.id / selection were rewritten to selected by migration 3.8.26, and the MCP encoder still maps them on the way in);
  • 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.