# Conditional Logic — User & Developer Guide

**Status of this document**: written against the codebase as of Milestone
6 Phase A, updated through the data-source wiring round (v0.28.1). It
documents exactly what is built and working today — nothing here
describes a planned feature as if it already works. All nine data
sources now resolve real data (§5); the one remaining gap is noted in
§15 (Live Preview can't yet test non-field sources inside the Builder).

**A note on screenshots**: this environment does not have access to a
running WordPress site or a browser, so the walkthroughs below use
labeled text diagrams of the actual UI instead of real screenshots. Every
diagram reflects real component structure and copy pulled directly from
the source (`assets/src/builder/logic/ui/*.js`), not an approximation —
but they are not pixel-accurate renders. Before this becomes public
documentation, each diagram should be replaced with a real screenshot
taken from a live install; the places to do that are marked
**[SCREENSHOT]**.

---

## Table of contents

1. [What conditional logic does](#1-what-conditional-logic-does)
2. [Quick start](#2-quick-start)
3. [The Rule Builder, screen by screen](#3-the-rule-builder-screen-by-screen)
4. [Understanding groups: AND, OR, and NOT](#4-understanding-groups-and-or-and-not)
5. [Every data source](#5-every-data-source)
6. [Every operator](#6-every-operator)
7. [Every action](#7-every-action)
8. [Rule examples](#8-rule-examples)
9. [Nested rule examples](#9-nested-rule-examples)
10. [Debugging a rule (the Rule Debugger)](#10-debugging-a-rule-the-rule-debugger)
11. [The Rule Inspector](#11-the-rule-inspector)
12. [Migration notes](#12-migration-notes)
13. [Developer extension guide](#13-developer-extension-guide)
14. [JSON schema reference](#14-json-schema-reference)
15. [Known limitations](#15-known-limitations)

---

## 1. What conditional logic does

Conditional logic lets a field react to what a visitor has already typed
elsewhere in the form — showing itself, hiding itself, becoming required,
or becoming read-only, based on a rule you build visually. A common
example: only ask for a VAT number if the visitor picked a business
customer type in an EU country.

Every rule you build is evaluated by the **same engine** in three places,
so what you see in the Builder's Live Preview is exactly what happens on
the real, live form and exactly what's enforced when the form is
submitted:

```
Builder UI  →  field.logicRule  →  Shared Evaluator  →  Front-end display
                                                       →  Submission handling
```

There is only one rule format (`field.logicRule`) and one evaluator —
see §12 if you have forms built before this was true.

## 2. Quick start

1. Open a form in the Builder and click the field you want to control
   (the one that should show/hide/etc.).
2. In the right-hand settings panel, find **Conditional Logic** and check
   **Enable conditional logic**.
3. This opens the Rule Builder for that field, seeded with one empty
   condition inside one AND group.
4. Fill in the condition: pick a data source (almost always **Form
   Field**), pick which field to check, pick an operator (e.g. **is**),
   and type the value to compare against.
5. Below the condition tree, under **Then:**, an action is already
   present targeting this field — leave it as **Show**, or change it.
6. Save the form. That's a complete rule.

**[SCREENSHOT]** — the field settings panel with the "Enable conditional
logic" checkbox and the resulting Rule Builder panel open underneath it.

## 3. The Rule Builder, screen by screen

Opened from any field's settings panel once "Enable conditional logic" is
checked (`RuleBuilder.js`). Top to bottom:

```
┌─────────────────────────────────────────────────────┐
│ ☑ Rule enabled                                       │
│                                                       │
│ Rule Name (optional)                                 │
│ [ e.g. Show VAT for EU countries              ]      │
│                                                       │
│ "When Country is "Germany" OR Country is "France"    │
│  AND NOT Customer Type is "Individual": show VAT      │
│  Number"                          ← live summary      │
│                                                       │
│ ┌─ Match [AND][OR]  of these  ☐ NOT ────────────────┐│
│ │  ⠿ Form Field ▾ [Country ▾] [is ▾] [Germany     ] ⧉🗑│
│ │  ⠿ Form Field ▾ [Country ▾] [is ▾] [France      ] ⧉🗑│
│ │  + Add Condition    + Add Group                    ││
│ └─────────────────────────────────────────────────────┘│
│                                                       │
│ Then:                                                │
│  [Show ▾] [VAT Number ▾]                        🗑    │
│  + Add Action                                        │
│                                                       │
│ ── Live Preview ──────────────────────────────────── │
│  Country [Germany____]  Customer Type [Business___]  │
│  ✅ Rule PASSED — the action below will execute       │
│   (Show VAT Number)                                  │
│  PASS  AND group                                     │
│    PASS    OR group                                  │
│      PASS      Country is "Germany" (actual: "Germany")│
│      FAIL      Country is "France" (actual: "Germany")│
│  Evaluated in 0.031ms                                │
│                                                       │
│ ── Rule Inspector ────────────────────────────────── │
│  Rule ID: rule_migrated_f_vat                        │
│  Schema Version: 1                                   │
│  Evaluation Order: 1 of 2                             │
│  Referenced Fields: Country, Customer Type           │
│  Depends On Rules: (none)                            │
│  Potential Circular References: None detected.       │
└─────────────────────────────────────────────────────┘
```

**[SCREENSHOT]** replaces the diagram above once available.

Every part of this is live and interactive:
- **⠿** drag handles reorder conditions/groups within their own parent
  group (drag-and-drop is currently mouse/touch only — see §15).
- **⧉** duplicates a condition or group in place; **🗑** deletes it.
- The collapse arrow (▸/▾, not shown above) next to a nested group's
  header hides its contents without deleting them.
- **+ Add Condition** / **+ Add Group** work at any nesting depth — a
  group can contain other groups without limit.
- The summary sentence, the Live Preview, and the Rule Inspector all
  update instantly as you edit — nothing requires a save first.

## 4. Understanding groups: AND, OR, and NOT

A rule's trigger is a tree of **groups** and **conditions**. Every group
has:

- A **combinator**: `AND` (every child must be true) or `OR` (at least
  one child must be true).
- An optional **NOT**: flips the group's own result.

Groups can contain other groups, at any depth — this is what lets you
express things a simple flat rule list can't, like *"(Country is Germany
OR Country is France) AND NOT (Customer Type is Individual)"*:

```
AND
├── OR
│   ├── Country is "Germany"
│   └── Country is "France"
└── NOT AND
    └── Customer Type is "Individual"
```

Read it top-down: *"show this field if (Germany OR France) AND it is
NOT the case that Customer Type is Individual."*

An empty group (no conditions yet) is treated as **always true** — this
is standard boolean-algebra convention (an empty AND is vacuously true),
not a bug; it's why a freshly-enabled rule with no conditions filled in
yet doesn't silently break anything.

## 5. Every data source

Chosen from the first dropdown on a condition row. **All nine now
resolve real data** (this was a gap in the original Phase A release —
seven of nine were registered but unwired; closed in a follow-up round,
see §12). Every source is deliberately **scoped**: ChargeForms never
fetches more than the specific keys your form's own rules actually
reference — there is no "dump everything" mode, and post/user meta keys
starting with `_` (WordPress's own "private/internal" convention) are
never exposed, full stop, even if a rule names one.

| id | Label | What it reads |
|---|---|---|
| `field` | Form Field | Another field's submitted value, by field id. The overwhelming majority of real-world rules use only this. |
| `hiddenField` | Hidden Field | Same underlying value map as `field` — a Hidden field's value, if you've populated one (e.g. via a URL parameter passed into the form, or a default value). |
| `urlParam` | URL Parameter | A query-string parameter from the page the form is embedded on (e.g. `?utm_campaign=...`). Resolved live from the browser's own URL on the front-end; resolved from the submitting page's Referer header at submission time (the same approach already used for entry source-URL tracking). |
| `query` | Query String | Identical to URL Parameter — kept as a separate source id for a possible future distinction between routing params and query params, not a different behavior today. |
| `cookie` | Cookie | A browser cookie's value, by name. Resolved live from `document.cookie` on the front-end. **Asymmetry to know about**: PHP can read `HttpOnly` cookies at submission time; front-end JS cannot (that's what `HttpOnly` is for) — a rule using an `HttpOnly` cookie will correctly enforce at submission but can never show/hide anything live on the page. |
| `currentUser` | Current User | One of a small whitelist of fields for the **logged-in visitor viewing the form**: `id`, `login`, `email`, `display_name`, `first_name`, `last_name`. Empty for logged-out visitors — there is no way to reference a *different* user. |
| `userRole` | User Role | The logged-in visitor's WordPress role(s), as an array — pair this with the `contains`/`not_contains` operators ("roles contains administrator"), not `is`/`in_list` (those compare against a single scalar value, not "is this array's contents one of..."). Empty array for logged-out visitors. |
| `postMeta` | Post Meta | A meta value from the post/page the form is embedded on, by meta key. Requires the form to actually be embedded in a post/page (not, for example, a bare REST-only context) — resolves to empty otherwise. |
| `userMeta` | User Meta | A meta value from the logged-in visitor's own profile, by meta key — never another user's. Empty for logged-out visitors. |

## 6. Every operator

Shown in the second/third dropdown on a condition row, filtered to what
makes sense for the condition's data type where the UI knows it. All
comparisons are **case-insensitive** and both sides are **trimmed** of
leading/trailing whitespace before comparing; when both sides parse as
numbers, the comparison is numeric (so `"9"` correctly sorts before
`"10"`, not after it as a naive string comparison would).

| id | Label | Applies to | Needs a value? | What it checks |
|---|---|---|---|---|
| `is` | is | text, number, date, boolean, choice | Yes | Exact match |
| `is_not` | is not | text, number, date, boolean, choice | Yes | Not an exact match |
| `contains` | contains | text, list | Yes | Substring present (or, for a list/array value, the exact item is present) |
| `not_contains` | does not contain | text, list | Yes | Substring absent |
| `starts_with` | starts with | text | Yes | Value begins with the given text |
| `not_starts_with` | does not start with | text | Yes | Value does not begin with the given text |
| `ends_with` | ends with | text | Yes | Value ends with the given text |
| `not_ends_with` | does not end with | text | Yes | Value does not end with the given text |
| `greater_than` | greater than | number, date | Yes | Numeric/date comparison |
| `greater_or_equal` | greater than or equal to | number, date | Yes | Numeric/date comparison |
| `less_than` | less than | number, date | Yes | Numeric/date comparison |
| `less_or_equal` | less than or equal to | number, date | Yes | Numeric/date comparison |
| `between` | is between | number, date | Yes (`lo,hi`) | Inclusive range check |
| `not_between` | is not between | number, date | Yes (`lo,hi`) | Outside an inclusive range |
| `is_empty` | is empty | text, number, date, list, choice | No | Value is blank/null/empty array |
| `is_not_empty` | is not empty | text, number, date, list, choice | No | Value has something in it |
| `in_list` | is one of | text, number, choice | Yes (comma-separated) | Value matches any item in a list |
| `not_in_list` | is not one of | text, number, choice | Yes (comma-separated) | Value matches no item in a list |
| `regex` | matches pattern (regex) | text | Yes | Value matches a regular expression. **Use with care** — see §15's security note. |
| `is_true` | is checked / true | boolean | No | Checkbox-style field is checked/true |
| `is_false` | is unchecked / false | boolean | No | Checkbox-style field is unchecked/false |

`is_empty`/`is_not_empty`/`is_true`/`is_false` don't show a value input in
the Rule Builder — there's nothing to type.

## 7. Every action

Chosen in the **Then:** section, one row per action — a single rule can
have more than one action, and each targets a field independently.

| id | Label | Effect |
|---|---|---|
| `show` | Show | Field becomes visible when the rule matches. **Important**: a field targeted only by a `show` action (no `hide` action from any rule) starts **hidden** by default and is only revealed once its rule matches — see §12 if you're wondering why. |
| `hide` | Hide | Field becomes hidden when the rule matches. A field targeted only by `hide` starts **visible** by default. |
| `enable` | Enable | Field becomes interactive. |
| `disable` | Disable | Field becomes non-interactive (but stays visible, unlike Hide). |
| `require` | Require | Field becomes required for submission, independent of its own static "Required" setting. |
| `unrequire` | Make optional | Field is no longer required, independent of its own static "Required" setting. |
| `readonly` | Make read-only | Field displays its value but can't be edited. |
| `editable` | Make editable | Reverses read-only. |

All actions are **declarative**: every enabled rule whose trigger matches
runs its actions, in a consistent order (see §11's "Evaluation Order").
There's no "first match wins" — if two rules both target the same
field, the one with the higher `priority` value applies last and wins a
direct conflict (this is set via the API/JSON only today; there's no
priority control in the visual Rule Builder yet — see §15).

## 8. Rule examples

**Show a field when another equals a specific value**

> Show **VAT Number** if **Country** is **Germany**

One condition, one AND group, one Show action. The simplest possible rule
— most real-world forms only ever need this shape.

**Show a field unless a checkbox is checked**

> Show **Details** if **Skip Details** is unchecked (`is_false`)

**Require a field only for one choice**

> Require **Company Name** if **Account Type** is **Business**

Uses the `require` action instead of `show`/`hide` — the field stays
visible either way, but submission is blocked without a value only when
the condition matches.

**Hide a field while someone is still filling out an earlier step**

> Hide **Confirmation Code** if **Email** is empty (`is_empty`)

## 9. Nested rule examples

**(A OR B) AND NOT C** — the showcase form's real example

> Show **VAT Number** if (**Country** is **Germany** OR **Country** is
> **France**) AND NOT (**Customer Type** is **Individual**)

```
AND
├── OR
│   ├── Country is "Germany"
│   └── Country is "France"
└── NOT AND
    └── Customer Type is "Individual"
```

You can build and inspect this exact rule yourself: go to **New Form →
Developer / QA → Builder Showcase (QA/Dev)**, create the form, and open
the **VAT Number** field's settings.

**Three-way OR nested inside an AND**

> Show **Expedited Shipping Note** if **Country** is one of **United
> States, Canada, Mexico** (`in_list`) AND **Order Total** is greater
> than **100**

```
AND
├── Country in_list "United States, Canada, Mexico"
└── Order Total greater_than "100"
```

Shows that `in_list` often replaces what would otherwise be a 3-condition
OR group — pick whichever reads more clearly for the rule you're
building; both are fully supported.

**Deeply nested, mixed AND/OR/NOT** (structurally — for illustration of
how deep this can go, not a realistic real-world rule)

```
AND
├── OR
│   ├── AND
│   │   ├── Country is "Germany"
│   │   └── Customer Type is "Business"
│   └── AND
│       ├── Country is "France"
│       └── Customer Type is "Business"
└── NOT OR
    ├── Order Total is_empty
    └── Discount Code is_not_empty
```

There is no depth limit enforced by the engine — the Rule Builder UI
renders any depth you build, indenting each nested level.

## 10. Debugging a rule (the Rule Debugger)

The **Live Preview** panel inside every field's Rule Builder is also the
Rule Debugger — the same trace data drives both, so what you see while
debugging is never a simplified or different explanation from what
actually ran.

**How to read it:**

1. Type sample values into the boxes shown (one per field your rule's
   trigger actually reads — discovered automatically, you don't have to
   guess which fields matter).
2. The banner above the trace tree tells you immediately: **PASSED**
   (which action will fire) or **FAILED** (with a plain-English reason
   naming the *specific* condition that caused the failure, not just "the
   rule didn't match").
3. The indented tree below shows **every** condition and group with a
   PASS/FAIL badge and, for each condition, both the value it expected
   and the value it actually read — so you can immediately see a typo
   (comparing against `"germany"` when the field's real options are
   capitalized `"Germany"`, for instance — remember comparisons are
   case-insensitive, so that specific example wouldn't actually be a
   problem, but a genuinely wrong field reference or operator choice
   will show up exactly this way).

**A worked example** — debugging why a field never shows:

```
Rule FAILED — Country is "France" = FALSE (actual: "Spain")
FAIL  AND group
  FAIL    OR group
    FAIL      Country is "Germany" (actual: "Spain")
    FAIL      Country is "France" (actual: "Spain")
  PASS    NOT AND group
    FAIL      Customer Type is "Individual" (actual: "Business")
```

Reading bottom-up: the visitor's Country ("Spain") matched neither
Germany nor France, so the OR group failed — and since the whole rule is
an AND of that OR group with the NOT group, one failing branch is enough
to fail the whole rule, regardless of the NOT branch passing. The fix
here is either adding Spain to the OR group or realizing the rule was
never meant to apply to Spain in the first place.

**[SCREENSHOT]** — the Live Preview panel with real color-coded PASS/FAIL
badges (green/red).

## 11. The Rule Inspector

Below Live Preview, for every rule:

| Field | What it tells you |
|---|---|
| **Rule ID** | The rule's stable identifier — useful when comparing a rule across an export/import or a support request. |
| **Schema Version** | Which version of the Rule/LogicNode shape this rule uses (currently always `1`). |
| **Evaluation Order** | This rule's position in the whole form's dependency-ordered evaluation sequence — rules that read a field another rule writes are always ordered after it. |
| **Referenced Fields** | Every field this rule's trigger actually reads, by label. |
| **Depends On Rules** | Other rules on this form whose actions write to a field this rule reads — i.e., rules that must run before this one for its result to be meaningful. |
| **Potential Circular References** | Whether this rule is part of a circular dependency (Rule A depends on a field Rule B sets, and Rule B depends on a field Rule A sets). A rule caught in a cycle is automatically **excluded** from evaluation (fails safe, never loops or crashes) until you break the cycle. |

## 12. Migration notes

Forms built before Milestone 6 stored conditional logic under a
different, simpler key: `field.conditional` (a flat `{ enabled, action,
groups }` shape, no more than one level of grouping, no NOT). That shape
is **fully retired** — every consumer (the Builder, the front-end, form
submission) now reads only `field.logicRule`.

**You don't need to do anything.** The conversion is automatic and
permanent:

- The first time the plugin loads after updating, every existing form is
  converted once, in the database, from `field.conditional` to
  `field.logicRule`. Your rules, including which fields they reference
  and whether "Show" or "Hide" was selected, carry over exactly.
- A rule that was **disabled** (the "Enable conditional logic" checkbox
  was unchecked) carries over as a disabled `logicRule`, not deleted —
  reopening that field's settings and re-checking the box shows exactly
  what was there before.
- Anything you create, import, or duplicate from this point forward is
  written in the new format directly; there's no ongoing translation step
  happening on every page load.

**One real behavior fix that shipped alongside this migration**: in the
old system (and briefly, unintentionally, in an early version of the new
engine), a "Show field X if Y" rule with no explicit opposite case could
fail to actually hide X when Y was false. This is fixed — a field
targeted only by a `Show` action now correctly starts hidden and is only
revealed when its condition matches (see §7's note on the `show` action).
If a rule ever behaved differently before this fix than the description
in this guide, that's the reason, and the current behavior is the correct
one.

**If you have a form export (JSON) from before this migration**:
importing it still works — the same one-time conversion runs on import,
so an old export file never introduces the retired shape back into your
site.

## 13. Developer extension guide

The engine is built around four registries — the exact same pattern
ChargeForms's own field-type system (`fields/registry.js`) already uses.
Built-in operators/actions/data sources are simply the first,
unprivileged caller of these same functions; a plugin registering its own
has equal standing.

### Registering a custom operator

**JavaScript** (runs in the Builder — needed if you want your operator
selectable in the Rule Builder UI):

```js
import { registerOperator } from 'chargeforms/logic'; // conceptual import path — see assets/src/builder/logic/index.js for the real barrel export

registerOperator( {
	id: 'within_radius_km',
	label: 'is within (km) of',
	appliesTo: [ 'text' ], // controls which condition rows offer it
	evaluate: ( fieldValue, targetWithRadius ) => {
		// Your comparison logic. Must return a boolean. Never throw —
		// an invalid input should resolve to `false`, matching how the
		// built-in `regex` operator handles a malformed pattern.
		return true; // ... real geodistance logic here
	},
} );
```

**PHP** (required for the operator to actually be enforced on real form
submissions — the JS registration above only affects the Builder's own
Live Preview):

```php
add_action( 'plugins_loaded', function () {
	\ChargeForms\Includes\Logic\Operator_Registry::register( [
		'id'       => 'within_radius_km',
		'label'    => 'is within (km) of',
		'evaluate' => function ( $field_value, $target_with_radius ) {
			return true; // the same logic, in PHP
		},
	] );
} );
```

**Register both, and keep them behaviorally identical.** The whole point
of this engine is that the Builder's Live Preview and the real submission
enforcement never disagree — a custom operator that behaves differently
in JS vs. PHP reintroduces exactly the "two systems" problem this
architecture exists to prevent.

### Registering a custom action

```php
add_action( 'plugins_loaded', function () {
	\ChargeForms\Includes\Logic\Action_Registry::register( [
		'id'    => 'set_background_color',
		'label' => 'Set background color',
		'apply' => function ( array $state, array $action ) {
			$state[ $action['target'] ]['backgroundColor'] = $action['value'] ?? '#ffffff';
			return $state;
		},
	] );
} );
```

`apply()` must return a **new** state array (or the same reference if
nothing changed) — never mutate the `$state` argument in place. The JS
mirror is identical in shape (`registerActionType({ id, label, apply })`
in `assets/src/builder/logic/actions/registry.js`).

### Registering a custom data source

```php
add_action( 'plugins_loaded', function () {
	\ChargeForms\Includes\Logic\Data_Source_Registry::register( [
		'id'      => 'woocommerce_cart_total',
		'label'   => 'WooCommerce Cart Total',
		'resolve' => function ( $ref, array $context ) {
			return WC()->cart ? WC()->cart->get_total( 'edit' ) : null;
		},
	] );
} );
```

`resolve( $ref, $context )` should return `null`/`undefined` rather than
throwing when the data isn't available — every built-in operator already
treats an absent value as "empty," so this fails safe automatically.

### What you cannot yet extend

- **The Rule Builder's condition-row UI** doesn't have a plugin slot for
  a custom data source to contribute its own input widget — a custom
  source like `woocommerce_cart_total` above will work correctly when a
  rule evaluates, but its condition row in the Builder will show the
  generic "Parameter/key name" text box, not a purpose-built picker. This
  is a known, documented gap (see §15).
- **`registerEvaluator`** — a fourth registry for swapping the evaluation
  *strategy* itself — is named in the architecture design but not yet
  implemented in code. Don't build against it yet.

## 14. JSON schema reference

A field's `logicRule`, in full:

```json
{
  "id": "rule_migrated_f_vat",
  "schemaVersion": 1,
  "name": "Show VAT for EU business customers",
  "enabled": true,
  "scope": "field",
  "trigger": {
    "id": "g_root",
    "type": "group",
    "combinator": "AND",
    "not": false,
    "children": [
      {
        "id": "g_country",
        "type": "group",
        "combinator": "OR",
        "not": false,
        "children": [
          { "id": "c1", "type": "condition", "source": "field", "ref": "f_country", "operator": "is", "value": "Germany" },
          { "id": "c2", "type": "condition", "source": "field", "ref": "f_country", "operator": "is", "value": "France" }
        ]
      },
      {
        "id": "g_not_individual",
        "type": "group",
        "combinator": "AND",
        "not": true,
        "children": [
          { "id": "c3", "type": "condition", "source": "field", "ref": "f_customer_type", "operator": "is", "value": "Individual" }
        ]
      }
    ]
  },
  "actions": [
    { "type": "show", "target": "f_vat_number" }
  ],
  "priority": 0
}
```

- `trigger` is always a `group` node at the root, recursively containing
  `group` and `condition` nodes.
- A `condition` node: `{ id, type: "condition", source, ref, operator,
  value }`.
- `scope` is always `"field"` today — a reserved `"form"` scope exists in
  the design for Phase B's form-wide workflow rules (page-skip, dynamic
  defaults) and isn't implemented yet.
- `priority` (number, default `0`) resolves conflicts when two rules'
  actions target the same field and both match — the higher value
  applies last and wins.

## 15. Known limitations

Documented honestly so this guide doesn't overstate what's built —
carried forward from the Phase A production audit
(`docs/research/milestone-06-phase-a-production-audit.md`):

- **The Rule Builder's Live Preview can't test non-field data sources
  yet.** All nine sources resolve real data now (§5), but the sample-value
  inputs in Live Preview (§10) are only generated for `field`-source
  conditions — to see how a `urlParam`/`cookie`/`currentUser`/etc.
  condition actually evaluates, you currently have to test it on the real
  front-end page rather than inside the Builder.
- **Drag-and-drop reordering is mouse/touch only** — there is no keyboard
  path to reorder conditions or groups yet.
- **No searchable field picker** — at 50+ fields, the field dropdowns in
  the Rule Builder are plain scrollable lists.
- **No rule-priority control in the visual UI** — `priority` exists in
  the schema and engine, but can currently only be set by editing the
  JSON directly (e.g. via the API), not through the Rule Builder.
- **No custom-data-source picker UI slot** — see §13.
- **The `regex` operator has no execution time/complexity limit.** A
  form author choosing a pathological pattern combined with adversarial
  visitor input is a theoretical denial-of-service vector. Use it
  carefully, and prefer `contains`/`starts_with`/`in_list` where they'd
  do the same job.
- **No enforced maximum nesting depth or rule count.** Extremely large
  rule sets (hundreds of rules with deep nesting) can be slow to evaluate
  in PHP — see the performance numbers in the Phase A audit before
  building something at that scale.
- **This guide's screenshots are text diagrams**, not real captures — see
  the note at the top of this document.
