# CBX 4 — breaking changes log (for customizations)

> The running, dated index of every core change that can break customization code in data/customization/. Append a new entry at the top whenever core work intr…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/breaking-changes-log. Last updated 2026-08-25.

---
The running, dated index of every core change that can break customization code in `data/customization/`.
**Append a new entry at the top whenever core work introduces another breaking change.** Each entry links
to the playbook that carries the detailed fix.

Legend: 🟢 auto-converted (shim present, code still works, deprecation logged) · 🟡 partial (safe cases
shimmed, some need manual edits) · 🔴 manual (must edit your customization).

---

## 2026-08-17 — admin markup drops its generated ids: view wrappers, property wrappers, list rows, filter controls 🔴

The Kenedo admin markup carried generated `id` attributes that were never safe to duplicate —
and the moment a form holds two embedded lists of the same child model (the custom-question-type
answers-list pattern), duplicate they did. All four families are gone; **the classes, which were
always there alongside, are the selector surface now**:

- **`id="view-<viewname>"`** on view wrappers — gone everywhere (the kenedo default form, every
  hand-rolled admin template, the invoice/quotation/orderslip PDFs, PSP result pages). Select
  with the class `view-<viewname>` (on the `getViewAttributes()` wrapper it always sat next to);
  scripts that need the view NAME read `data-view-id`, which the hand-rolled templates now emit
  like the generic wrapper always has.
- **`id="property-name-<prop>"`** on property and group wrappers — gone. The identical CLASS
  `property-name-<prop>` has always been on the same element. URL fragments of the form
  `#property-name-<prop>` (the AI settings deep links, bookmarks) keep scrolling: kenedo.js now
  honours that hash against the class.
- **`id="item-id-<recordId>"`** on list rows — gone without replacement. `data-item-id` on the
  same `<tr>` is the machine-readable handle and always was.
- **`id="<table>_<column>"`** on list filter inputs/selects (search boxes, dropdown filters,
  translatable search, join filters) — gone without replacement. Submission runs on the `name`;
  the handlers are class-delegated.

`KenedoProperty::getCssId()` still exists and still answers `property-name-<prop>` (customization
code may call it), but nothing in core writes it into markup any more.

Two mechanisms follow from the same cleanup:

**Every listing has a state instance key.** The remembered list state (user filters, pagination,
sorting) is stored under `KenedoView::getListInstanceKey()` — default: the lowercased view name,
so standalone list pages keep the exact state slots they always had. Embedded lists override it:
childentries sets `<childview>-<propertyName>`, so two intra-lists of the same child model on one
form no longer share sort/page/filter state. The key travels with list refreshes as the
`list_instance_key` request parameter (part of `listingData`). A view embedding a listing by hand
sets `$view->listInstanceKey` before `prepareTemplateVars()`.

**The subview-ready registry is gone; views announce themselves with an event.** On every
processing pass (initial page load and every XHR injection), each view wrapper dispatches the
bubbling native event **`cbx:view-ready`** with `detail: {view, wrapper}` — `detail.wrapper` is
the instance, so listeners work even with the same view twice on a page. The convention for
future lifecycle events: `cbx:<subject>-<state>`, native CustomEvent, bubbling, facts in
`detail`. (Native deliberately: jQuery-triggered events are invisible to `addEventListener`.)

| Your customization | What happens | Do |
|---|---|---|
| CSS/JS selecting `#view-<name>`, `#property-name-<prop>`, `#item-id-<n>` or a filter id | Selector matches nothing — styles vanish, scripts no-op silently | Switch to the class (`.view-<name>`, `.property-name-<prop>`, `.cb-list-row[data-item-id="<n>"]`); filter controls: select by `name` or `.cb-list-filter` 🔴 |
| Templates echoing `getCssId()` into an `id=` attribute | Still renders, but reintroduces the duplicate-id hazard | Drop the id, keep `renderCssClasses()` 🔴 |
| Links to `…#property-name-<prop>` | Keep working (kenedo.js scrolls to the class) | Nothing 🟢 |
| `kenedo.registerSubviewReadyFunction(...)` | Keeps working through a deprecation shim (translated into a `cbx:view-ready` listener; logged to the console) | Move to `document.addEventListener('cbx:view-ready', …)` at leisure 🟢 |
| `kenedo.runSubviewReadyFunctions(...)` / reading `kenedo.subviewReadyFunctions` | Gone — TypeError | Dispatch or listen for `cbx:view-ready` instead 🔴 |
| Hand-built list refresh URLs | Still work, but write state under the view-name key | Include the list's `list_instance_key` parameter (it is in `.cb-list-data`) 🟡 |

## 2026-08-13 — two breaks a real CB3 port surfaced, written down: slimmed rule-helper rows, enforced JS handler set 🔴

Porting the Beta Calco (CB3) custom question types to CBX 4 hit two breaks that had never made
this log — both are core changes from earlier CB4 work, recorded now with their fixes so the next
port does not rediscover them the hard way.

**`ConfigboxRulesHelper::getQuestions()` rows no longer carry the question type.** The rows are
slimmed to `id, title, product_id, page_id, answer_count`. Every CB3-era rule-condition or
calc-term panel filters questions by type by reading it off the row
(`$question->question_type == 'lof'` — verified in a real CB3 customization); in CBX 4 that raises
an undefined-property warning which the admin error handler promotes to an exception, so the
**whole rule editor answers 500**, not just your tab. Resolve the type through the factory instead:
`ConfigboxQuestion::getQuestion((int)$row->id)->question_type`, in a try/catch for rows deleted
mid-edit.

**`configurator.registerQuestionType()` enforces the full nine-handler set.** The CB3-era
`configurator.registerQuestion()` name still works (deprecated alias), but registration now throws
— listing the missing methods — unless the object carries all nine handlers, and the throw
surfaces inside `initQuestions()`'s page walk: one incomplete module takes the **entire
configurator page** down (it never reaches `questions-init-done`). Port with
`configurator.defineQuestionType(type, methods)`, which fills the handlers you don't need with
no-ops and gives you standard validation-message handling.

| Your customization | What happens | Do |
|---|---|---|
| Rule condition / calc term panel reading `->question_type` (or `->element_type`) off `getQuestions()` rows | Rule editor 500s the moment your tab renders | Resolve via `ConfigboxQuestion::getQuestion()` (try/catch) 🔴 |
| JS type module registering fewer than nine handlers | The whole configurator page dies in `initQuestions()` | Register through `defineQuestionType()` 🔴 |
| `configurator.registerQuestion()` by name | Works (deprecated alias) | Rename to `registerQuestionType()` at leisure 🟢 |

Both failure modes are **loud in the browser but silent in the code**: nothing greps as obviously
wrong, the code was correct CB3. Find the hits with the CHEATSHEET's *question-type
customizations* grep (its Grep pack), which also flags the third porting trap in this family —
`getPrice()`/`getWeight()` overrides on type classes, which are **not consulted** (price through a
`calcmodel` formula instead; see CHEATSHEET §4 and
`customization/com_configbox_custom_question_types.md` §3).

**No data half.** Code-only; nothing to migrate.

**Key reference:** CHEATSHEET §4 (methods callouts) and §5 (slimmed rows) — both added with this
entry.

## 2026-08-11 — `getSku()` is now a `ConfigboxQuestion` method, and the order record calls it 🟡

The order record used to read the SKU it freezes onto a line straight off the answer
(`$answer->sku`). It now asks the question — `ConfigboxModelOrderRecord::createOrderRecord()` does
`$configuration->option_sku = (string) $question->getSku($selection->selection);` — so a custom
question type can put a SKU on an order line for a selection that is a typed value rather than an
answer id. The base implementation returns the picked answer's `sku`, so **for every stock question
type the frozen value is byte-identical to before**, including the `''` a non-answer selection wrote.

Three ways a customization can be affected — the first two are about the **name** being taken, the
third about an override that now misses a hook:

| Your customization | What happens | Do |
|---|---|---|
| A question sub-class that already declares `getSku()` with an **incompatible signature** (no parameter, extra required parameters) | **Fatal on load** — PHP rejects the incompatible override | Change it to `getSku($selection)`, or rename yours |
| A question sub-class that already declares `getSku($selection)` (or `getSku($x)`) as *its own* helper | No error — but core now **calls** it at checkout, so whatever it returns starts landing in `option_sku` | Check what it returns for a stored selection. If it was never meant for order lines, rename it |
| A `system_overrides/` copy of `ConfigboxModelOrderRecord` | Keeps the old `$answer->sku` read, so question sub-classes are ignored — silently | Port the one line, or drop the override (§ "Check for a purpose-built hook first" in `customization/com_configbox_overriding_controllers_and_models.md` §4) |

**Kind:** needs a manual edit in the first two cases, and there is no shim — the name is either
compatible or it is not. Case 1 **fatals** on load (`Declaration of X::getSku() must be compatible
with ConfigboxQuestion::getSku($selection)`), which is loud. Cases 2 and 3 are **silent**: no error,
just a SKU that appears (or fails to appear) on order lines. Find both in one command:

```bash
grep -rn "function getSku" data/customization/
grep -rln "ConfigboxModelOrderRecord\|createOrderRecord" data/customization/system_overrides/
```

**No data half.** This is a code change only: `ConfigboxAnswer::$sku` is unchanged, the
`#__cbcheckout_order_configurations.option_sku` column is unchanged, the API's answer projection
(`ConfigboxConfiguratorApiHelper::projectAnswer()`) still emits the answer's own `sku`, and no order
rows are rewritten — the SKU is frozen at checkout, so existing orders keep what they were placed
with.

**Playbook:** `customization/com_configbox_custom_question_types.md` §3 — "Giving the order line a
SKU", with a worked override and the four rules the caller relies on.

## 2026-08-11 — `platforms` hides a property instead of removing it 🟡

**Every property now exists on every platform.** `KenedoModel::getProperties()` no longer filters on
the `platforms` key — there is one shape on Joomla, WordPress, Magento 2 and standalone, stored,
readable and writable everywhere, because there is one schema and one set of migrations.

`platforms` still says where a property *applies*, but it now governs **presentation only**:
`KenedoProperty::isVisible()` is false where it does not apply (the field gets the same
`invisible-field` class an `'invisible'` definition produces) and `isRequired()` is false with it —
that second half is load-bearing, or a hidden field could refuse a save nobody can satisfy.

Why: while the runtime shape differed per host, three surfaces disagreed about which fields exist —
the entity API's writable set (filtered), the generated schemas (the union) and
`cbx_describe_entity` (the union). On Magento, describe advertised `baseprice` and the create then
refused it.

| Area | Old | New | Status |
|---|---|---|---|
| `getProperties()` on a host the property excludes | property **absent** | property present, hidden, never required | 🔴 code using absence as a platform test — `isset($props['baseprice'])`, `array_key_exists(...)` — is now always true. Test the platform with `KenedoPlatform::getName()`, or ask `$property->appliesToThisPlatform()` |
| A **custom** definition declaring `'platforms'` | dropped the property on other hosts | hides it there | 🟡 the intent (not in the form, never required) still holds; the property is inert rather than gone |
| Reading/writing such a field off-platform | not possible — no property | accepted, stored, round-trips, nothing reads it | 🟢 |
| `getPropertiesForAllPlatforms()` | the unfiltered union, different from `getProperties()` | same set as `getProperties()` | 🟢 kept because it says what the caller means |
| `$property->isRequired()` | the declaration | the declaration **for the running platform** | 🟡 code generating docs or schemas from it now varies per host — read `$definition['required']` for the declaration |
| Generated schemas | no platform information | `x-configbox-platforms` on read AND write, plus a sentence of prose | 🟢 additive |

### New: `platformDefaults`, when a host needs a different starting value

The per-platform if-blocks these declarations replaced did two things — `['invisible'] = true` **and**
`['default'] = 0` — and `platforms` expresses only the first. The companion key carries the second:

```php
'platforms'        => array('joomla', 'wordpress', 'standalone'),
'platformDefaults' => array('magento2' => 0),
```

It changes the value a **new record is born with** on that host and nothing else — the column, its
DB-level default and every existing row are identical everywhere. Resolved inside
`KenedoProperty::getPropertyDefinition('default')`, so every caller that builds a blank record picks
it up: **a customization reading that accessor gets a platform-specific answer now.** Code that wants
the platform-neutral declaration reads `$definition['default']` from the array, which is what type
generation does. Reported as `x-configbox-platform-defaults`.

Playbook: [`model-property-customizations.md` §4.6](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/model-property-customizations) — the fix for
the absence test, with the two replacements. Key reference:
`../technical/com_configbox_property_definition_settings.md` §11–12.

## 2026-08-11 — deleting a record removes its files at the outermost commit 🟡

Deletion grew a cascade (`ConfigboxProductDeleteHelper`, and the `cbx_delete_product_deep` tool)
that walks a product's graph deepest-first inside one transaction. Two pieces of it reach
customization code.

**A property that owns files should say so.** `KenedoProperty::getDeletionFilePaths($id)` is new;
the base returns `array()`, and the file/image properties return the paths the record owns.
`KenedoModel::getDeletionFilePaths($ids)` collects them, and `KenedoDatabase::deferUntilCommit()`
runs the removal **after the outermost commit** — so a rollback anywhere in the cascade leaves every
file where it was, which is the one thing a rollback cannot undo by itself.

| Area | Old | New | Status |
|---|---|---|---|
| `KenedoProperty::delete()` signature | `($id, $data)` | **unchanged** | 🟢 deliberately: a third `$dryRun` parameter would have made every existing 2-parameter override an incompatible signature, a fatal on PHP 8 |
| A custom property type that owns files and unlinks them **inside** `delete()` | ran immediately | still runs immediately | 🟡 works, but a rolled-back delete has already destroyed the file. Move the paths into `getDeletionFilePaths()` and let core defer them |
| Custom code inside a delete needing to know what else is going | no way to ask | `KenedoModel::isBeingDeleted($modelName, $id)` — true for anything in the current cascade, registered under both the current and the legacy `admin`-prefixed model name | 🟢 additive |
| Deleting a product that has pages | refused | **still refused** — the cascade is a separate, explicitly confirmed operation | 🟢 |

`beginDeletionScope()` / `endDeletionScope()` bracket the walk; `isBeingDeleted()` is what a
shared-record guard should consult, so a record another product still uses is never taken along.

Reference: `helpers/productdelete.php`, and `tests/check-after-commit-queue.php` in the Joomla site
repo for the nesting behaviour.

## 2026-08-07 — the configurator stopped decorating questions and answers 🔴

`ConfigboxViewQuestion` used to write everything it computed for rendering onto the
`ConfigboxQuestion` and `ConfigboxAnswer` objects themselves — the css classes, the `applies` flag,
the resolved image URLs — and, worse, **straight over record columns**: `price` stopped being the
answer's price column and became the customer's current price, `description` became content-modified
HTML, `minval`/`maxval` became the *calculated* bounds rather than the static ones, and
`availibility_date` — a DATE column — came to hold the sentence "Available on 3 March 2026".

That state now lives on the **view** (`ConfigboxViewQuestion`) for the question, and on one
**`ConfigboxAnswerPresentation`** per answer — `$this->answers`, keyed by answer id, each holding a
reference to the answer it describes. The records are never written to. Two things follow:

1. A question or answer read *after* a render now holds its own columns. Code that relied on
   `$answer->price` being the live price after the view ran gets the raw column instead.
2. **Template overrides for question views must be updated.** Record reads are unchanged
   (`$answer->title`, `$answer->id`, `$question->question_type`, …) — only the view-computed values
   moved.

### Fixing a question-view template override

The loop changes, with the record aliased back so every record read in the body stays as it was:

```php
// before
<?php foreach ($this->question->answers as $answer) { ?>

// after
<?php foreach ($this->answers as $answerPresentation) {
    $answer = $answerPresentation->answer; ?>
```

Then move the view-computed reads across. `$this->question->X` → `$this->Y` — they are now
properties of the view itself, alongside `$this->selection`, `$this->price` and `$this->outputValue`:

| Old | New |
|---|---|
| `$this->question->applies()` | `$this->applies` |
| `$this->question->disableControl` | `$this->disableControl` |
| `$this->question->description` | `$this->description` (content-modified) |
| `$this->question->minval` / `maxval` | `$this->minValue` / `$this->maxValue` |
| `$this->question->elementImageSrc` | `$this->imageSrc` |
| `$this->question->elementImageCssClasses` | `$this->imageCssClasses` |
| `$this->question->elementImagePreloadAttributes` | `$this->imagePreloadAttributes` |
| `$this->question->getCssClasses()` | `$this->getCssClasses()` (or `$this->questionCssClasses`, unchanged) |

`$answer->X` → `$answerPresentation->Y`:

| Old | New |
|---|---|
| `$answer->cssId` | `$answerPresentation->cssId` |
| `$answer->cssClasses` | `$answerPresentation->getCssClasses()` |
| `$answer->applies` | `$answerPresentation->applies` |
| `$answer->disableControl` | `$answerPresentation->disableControl` |
| `$answer->isSelected` | `$answerPresentation->isSelected` |
| `$answer->description` | `$answerPresentation->description` (content-modified) |
| `$answer->price` / `price_recurring` | `$answerPresentation->price` / `priceRecurring` |
| `$answer->was_price` / `was_price_recurring` | `$answerPresentation->wasPrice` / `wasPriceRecurring` |
| `$answer->showAvailibilityInfo` | `$answerPresentation->showAvailabilityInfo` (typo fixed) |
| `$answer->availibility_date` (the sentence) | `$answerPresentation->availabilityText` |
| `$answer->pickerImageSrc`, `pickerPreload*`, `optionImage*` | same names on `$answerPresentation` |

`addCssClass()` / `removeCssClass()` / `getCssClasses()` are **gone from `ConfigboxQuestion` and
`ConfigboxAnswer`**. The question's live on the view (`$this->addCssClass(...)`), the answer's on its
`ConfigboxAnswerPresentation`. Custom question-type *views* that called `$question->addCssClass(...)`
in `prepareTemplateVars()` call `$this->addCssClass(...)`.

### What did NOT change

**`#configurator-data` is byte-compatible.** `ConfigboxViewQuestion::getConfiguratorData()` and
`ConfigboxAnswerPresentation::jsonSerialize()` emit the record's keys with the computed values over
the top — under the historical names,
typos included (`showAvailibilityInfo`, `availibility_date`, `elementImageSrc`). Storefront and
customization **JavaScript needs no changes**. Three deliberate exceptions, all additive or fixes:

- `elementImageSrc` now carries the image URL instead of always being `null` — see the fix below.
- `elementImageCssClasses` / `elementImagePreloadAttributes` are now always present (`''` when the
  question has no image) instead of appearing only for questions that have one.
- A question's `cssClasses` map no longer contains an empty-string key when the question has no
  custom classes, and the rendered `class` attribute loses the double space that produced.

### Two fixes that ride along

- **Question illustrations were rendering with an empty `src`.**
  `views/question/tmpl/question_decoration.php` reads `elementImageSrc`, which was declared and read
  but assigned by nothing, anywhere. `$this->imageSrc` now carries `el_image_href`. If
  a site relied on question images being invisible, they will now appear.
- **Cross-render leakage.** `ConfigboxQuestion::getQuestion()` returns a `clone`, and a clone is
  shallow — every clone shared one set of `ConfigboxAnswer` objects. A second render in the same
  process therefore saw the first render's decoration: content modifiers applied twice, and a
  `selected` class left on an answer that had since been deselected. With nothing decorating the
  records, this cannot happen.

## 2026-07-31 — frontend endpoints answer the envelope: login/logout, customer-form save, cart links 🟡

The response-contract migration reached the storefront's remaining `{success: …}` endpoints. The
outcome now travels in the **HTTP status**; a 2xx body is `{data, meta}`, a 4xx/5xx body is RFC 9457
problem details (`{code, detail, errors[], validationIssues[]}`). There is **no `success` key** on
any of these anymore — customization JS that reads `response.success` sees `undefined`, which is
falsy, so an unconverted success handler runs its FAILURE branch.

| Endpoint | Old answer | New answer |
|---|---|---|
| `user::loginUser` | `{success, errorMessage}`, or a redirect for form posts | `200` + `data:{loggedIn, userId}` / `401` problem `LOGIN_FAILED`. Never redirects — the client navigates (the form's `return_success`/`return_failure` inputs are read by `user.js`) |
| `user::logoutUser` | `{success: true}` | `200` + `data:{loggedOut}` |
| `user::store` (customer form) | `{success, errors[], validationIssues[]}` with `fieldName` keys | `200` + `data:{id}` + `meta.feedback` / `422` problem whose `validationIssues` use **`field`** (not `fieldName`) / `500 STORE_FAILED` |
| `cart::addProductToCart` | `303` redirect to the cart or configurator page | `201` + `data:{cartPositionId, complete, redirectUrl, cart?\|missingSelections?}`. The client navigates to `data.redirectUrl` |
| `cart::removeCartPosition` / `editCartPosition` / `copyCartPosition` | redirect flow | **unchanged but deprecated** (logged as a legacy call). Stock links keep working: `configbox/cart::initLinkTasks` intercepts any `<a>` whose href names one of these tasks — customization template overrides included — and calls the envelope twins `removePosition`/`editPosition`/`copyPosition`, navigating on the response |
| `testautomation::*` (E2E only) | `{success, error}` | envelope / problem+json. Only the Playwright suite consumes this |

**What you need to do in customization JS** (the mechanical rewrite, one call site at a time):

1. Replace `server.makeRequest('user', 'store', data).done(function(response) { if (response.success === false) … })`
   with the choke point:
   ```js
   const response = await server.request('user', 'store', data);
   if (!response.ok) {
       // response.errors: display-ready strings; response.validationIssues: [{field, code, message}]
       return;
   }
   // response.data holds the payload, response.meta.feedback the success sentence
   ```
   `server.request()` resolves a normalised `CbResponse` for BOTH shapes (it still understands
   legacy endpoints), so converting a caller is safe even before every endpoint migrated. Full
   field table and worked examples: `docs/technical/com_configbox_frontend_requests.md`.
2. Field-level display: issues from these endpoints carry `field`; the model's own arrays carry
   `fieldName`. `customerform.js`'s `displayValidationIssues()` accepts both — code copying its old
   loop must read `issues[i].field || issues[i].fieldName`.
3. Login/logout callers: branch on `response.ok` (the status), never on a `success` key or an
   `errorMessage` string. A failed login is a **401**, not a 200-with-flag.
4. Templates: keep the stock link markup (or any markup whose href names the task) and the
   interception drives it — nothing to change. Only code that **scripts** `addProductToCart` over
   HTTP must read the `201` envelope and follow `data.redirectUrl` itself.
5. Anything still calling the deprecated redirect tasks directly shows up in the CBX log as
   a legacy call with the exact replacement named.

## 2026-07-27 — customer fields renamed: billing_*/shipping_* snake_case, gender columns dropped 🔴

The customer record (`#__configbox_users`) and the frozen order snapshot (`#__cbcheckout_order_users`)
carried three naming families: glued `billing*` columns, UN-prefixed columns that actually meant the
shipping address, and oddballs (`vatin`, `samedelivery`, id-references without `_id`). All renamed to
one convention — `billing_first_name`, `shipping_country_id`, `billing_postal_code`,
`shipping_address_line_1`, `vat_number`, `shipping_same_as_billing` — aligned with the commerce2
vocabulary. The dead `gender`/`billinggender` columns are dropped (the values become augment-time
derivations from the salutation). The rename reaches the Customer Fields configuration rows
(`user_field_definitions.field_name`), the customerform's input names and CSS-class/DOM contracts, the
validation-issue `fieldName` values, and the **operator-authored template placeholders** —
`helpers/updates/3.7.0.php` renames the columns, rewrites the field-definition rows and converts stored
notification texts ({billingfirstname} → {billing_first_name}, {country} → {shipping_country_name}, …) 🟠.
No shims: customization code referencing old names needs manual edits.

Playbook: [customer-field-rename.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/customer-field-rename).

## 2026-07-27 — file/image properties persist the filename by default; four option tags removed 🟢

Uploading a file and recording where it went were separate decisions. `image` and `file` only wrote
the uploaded name to their column behind a `SAVE_FILENAME` option tag; a definition that omitted it
uploaded the file to the data folder, left the column untouched, and reported nothing — the form came
back empty and the record pointed at the old file or at nothing. All ten shipped definitions carried
the tag, which is the clearest evidence it was never a real choice.

Persisting is now the default, with `NO_SAVE_FILENAME` to opt out.

Separately, three option tags were carried by 32 shipped definitions while being read by nothing — in
the framework, the views, the controllers or the encoded engine (checked against the plaintext
sources, since the bundles are opaque to grep).

| Tag | Old | New | Status |
|---|---|---|---|
| `SAVE_FILENAME` | persisted the filename | now the default; tag removed from definitions | 🟢 |
| `NO_SAVE_FILENAME` | — | opts out of persisting | new |
| `PRESERVE_EXT` | inert | removed | 🟢 |
| `NOFILTERSAPPLY` | inert | removed | 🟢 |
| `SKIPDEFAULTFIELD` | inert | removed | 🟢 |

**What you need to do:** nothing, in almost every case. An unknown option tag is ignored, so a
customization still listing any of these keeps working — `SAVE_FILENAME` is simply redundant now, and
the other three always were. Delete them when you next touch the definition.

**The one case that changes behaviour:** a custom `image`/`file` definition that *deliberately*
omitted `SAVE_FILENAME` will now start writing the filename to its column. If your customization
manages that column itself, add `NO_SAVE_FILENAME`. Nothing in stock did this.

Detail: `docs/technical/property-types/image.md`, `docs/technical/property-types/file.md`.

---

## 2026-07-27 — translation keys use underscores: `title_en_GB`, not `title-en-GB` 🔴

A translatable property named its per-language keys `<field>-<languageTag>`. A hyphen is not a legal
identifier character in most languages a client is written in, so `record.title-en-GB` reads as a
subtraction and every consumer had to use bracket access or rename the key on its way out. The
separator **and** the tag are now underscored at the point the key is built.

| Area | Old | New | Status |
|---|---|---|---|
| Record key for a translatable field | `$record->{'title-en-GB'}` | `$record->title_en_GB` | 🔴 rename in custom code |
| Form field name posted by the admin | `title-en-GB` | `title_en_GB` | 🔴 custom templates must follow |
| Reading a value in a custom model/property | hand-built `$name.'-'.$tag` | `KenedoLanguageHelper::getTranslationKey($name, $tag)` | 🔴 use the helper, do not concatenate |
| Translations from the record API | `{"title": {"en-GB": …}}` | `"title": "…"` plus `"title_en_GB": "…"` | 🔴 the nested map is gone entirely — read the flat per-language keys |
| **Sending** a translation to the API | `{"title": {"en-GB": …}}` | `{"title_en_GB": "…"}` | 🔴 the nested form is refused with 422, naming the key to use |
| MCP tool input keys | `title-en-GB` | `title_en_GB` | 🔴 update authoring scripts |

**Use the helper, not string concatenation.** `KenedoLanguageHelper::getTranslationKey()` is now the
single place the key is built, so the read side, the write side, the API projection, the MCP schemas
and type generation all derive it identically. The key had been assembled in **six** places, and
three of them were only found by chasing a failing test — including
`checkForDuplicateUrlSegment()`, which is *silent* when a key does not resolve. Concatenating your
own is how you join that list.

**Only `translatable` was affected.** Every other property already used an underscore — `image`'s
`_href`, `file`'s `_path`, `join`'s `_display_value`. The convention existed; one type was not
following it.

@see [exceptions-and-validation.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/exceptions-and-validation) for the other change of the same day

---

## 2026-07-27 — property definitions gain `dataType` / `nullable`, and update 3.6.5 normalises 187 columns 🟡

Property definitions now describe their **storage**, and update `3.6.5` moves the columns to match.
Both matter to customization code: a custom property type that says nothing about storage gets a
guess, and a customization that reads or writes an affected column may see a different type.

| Area | Old | New | Status |
|---|---|---|---|
| Custom property **type** | inherited storage silently | must declare `getStorageKind()` / `getDefaultDataType()`, or generation warns | 🟡 works, but the generated column is a guess until you declare |
| Custom **property definition** | no storage keys | may set `dataType`, `nullable` | 🟢 optional; defaults come from the type |
| Boolean columns | mixed `int` / `tinyint(1)` / `varchar(1)` | `varchar(1)` NOT NULL with the declared default | 🟡 custom SQL comparing to `0`/`1` as **integers** should compare to `'0'`/`'1'` |
| Plain text columns | `varchar(16 … 1024)`, inconsistent | `varchar(255)` | 🟡 custom SQL is unaffected; custom **schema** assumptions may not be |
| Dropdown columns | assorted widths | `varchar(50)` | 🟡 as above |
| Price-override blobs | `varchar(1024)` | `TEXT` | 🟢 wider, nothing truncates |
| `#__configbox_questions.maxval` / `minval` | `varchar(255)` | `decimal(20,8)`, non-numeric values nulled | 🔴 custom code storing a non-number there will now fail |
| 22 columns incl. `xref_list_product.list_id` / `product_id` | NULL allowed | **NOT NULL** | 🔴 custom inserts must supply them |
| Foreign keys pointing at a changed column | — | realigned automatically, constraint restored | 🟢 including keys on **your** tables |

**`varchar(1000)` was tried and is impossible**, in case you are tempted: utf8mb4 charges 4 bytes per
character, so a `varchar(1000)` column costs 4000 bytes of MySQL's 65535-byte row limit —
`#__configbox_questions` alone would need about 132000. The migration failed with "Row size too
large" before settling on 255.

**The migration refuses rather than invents.** A column that would have to narrow below the data it
holds is skipped with a log line; a NOT NULL change on a column holding NULLs is skipped rather than
backfilled with a value nobody chose. Clean the rows and re-run.

**Your foreign keys are handled.** MySQL will not modify a column another key points at, and
`FOREIGN_KEY_CHECKS=0` does **not** lift that — the type compatibility of a key is structural. The
migration looks the referencing side up in `information_schema`, so a key from your own
customization table is dropped, realigned and restored with its original delete/update rules rather
than being broken or silently skipped.

@see [../technical/com_configbox_property_definition_settings.md](https://docs.configbox.at/docs/4.0-preview/technical/property_definition_settings)

---

## 2026-07-27 — errors are exceptions; `setError()` / `getErrors()` are GONE 🔴

A refusal used to be `false` plus a string in `setError()`. That could not say whether the problem
was the caller's ("you left the title empty") or the system's ("the database is gone"), so every
layer guessed — and during `copy()`, which recurses into child entities, each level caught,
flattened the reason to a string and returned `false`. A fixable problem reached the operator as
*"A system error occurred during copying. Please notify your service provider."*

Refusals are now exceptions, and they say which kind they are:

| Class | Means | Answered with |
|---|---|---|
| `KenedoValidationException` | the caller can fix it | **422**, per-field issues |
| `KenedoSystemException` | the caller cannot | **500**, detail to the log only |
| `KenedoException` | base of both | — |

| Area | Old | New | Status |
|---|---|---|---|
| Custom property type's `check()` | `setError($msg); return false;` | `throw KenedoValidationException::forProperty($this, $msg)` | 🟡 `false` is still read as a refusal, but it can no longer carry a reason — it becomes "reported failed validation but gives no error message" |
| Code calling `validateData()` | `if (… === false) { getErrors(); }` | `try { … } catch (KenedoValidationException $e) { $e->getIssues(); }` | 🔴 **it never returns `false` any more.** `if (… === false)` is now dead code that lets a refused save look like a successful one |
| Code calling `store()` / `copy()` / `delete()` / `publish()` | `false` on any failure | returns its value, THROWS on any failure | 🔴 same — the `false` branch is unreachable, so an unconverted caller treats every failure as success |
| Reading `$model->getErrors()` after a failure | the messages | **fatal error, the method is gone** | 🔴 catch the exception and read `getMessages()` / `getIssues()` |
| `setError()` / `setErrors()` / `getError()` / `getErrors()` / `resetErrors()` | worked | **removed from `KenedoModel` and `KenedoProperty`** | 🔴 throw instead |
| Rollback on a failed `copy()` / `store()` | driven by the `false` return | driven by the exception unwinding; transactions roll back identically | 🟢 no change needed |
| A 500's log identifier | — | `meta.logIdentifier` on the problem body | 🟢 new; the detail itself stays in the log |
| Where a nested failure happened | lost | `meta.failedAt`, outermost first | 🟢 new; every level a `KenedoException` passes through adds itself |
| Extending `KenedoModelLight` | a model with an error bag and no constructor | **deprecated** — it is now an empty subclass of `KenedoModel` | 🔴 the four error methods are gone, and `KenedoModel::__construct()` requires a component: an override that skips `parent::__construct($component)` now throws |
| `catch (Exception)` around a Kenedo write | caught most things | **catch `Throwable`** | 🔴 an `Error` (e.g. `count(NULL)` on PHP 8) is not an `Exception`; catching the narrower type skipped the ROLLBACK and left the savepoint stack off by one |

**Why you should migrate rather than wait.** A refusal you throw arrives at the operator with its
reason and its field intact, through any number of recursion levels. One you report with
`setError()` + `false` is flattened by the first layer that catches it, and what the operator sees
is an apology for a system error that never happened.

**What breaks LOUDLY:** calling `setError()`, `setErrors()`, `getError()`, `getErrors()` or
`resetErrors()` on a model or property is now a fatal error. That is deliberate — a silent no-op
would leave customization code believing it had reported something.

**What breaks SILENTLY, and matters more:** a caller that still tests `if ($model->store($data) ===
false)`. Those methods do not return `false` any more, so the branch never runs and **a failed save
reads as a successful one**. Search your customizations for `=== false` against `store()`,
`copy()`, `delete()`, `publish()` and `validateData()` and replace each with a `try`/`catch`.

**Status: the conversion is complete.** Every call is gone — `KenedoModel`, `KenedoController`,
`KenedoProperty`, all ten stock property types, all fourteen models that had them, and the
controllers and helpers that read them. `KenedoSystemException` is now genuinely thrown (it never
was before), and carries the identifier of the log entry holding its detail. The compatibility shim
in `validateData()` that read `getErrors()` after a `false` return is removed.

`KenedoModelLight` is **deprecated** and now an empty subclass of `KenedoModel`, so code naming it
still resolves. It was the last place in the component where the retired protocol survived; the six
models that extended it (cart, cartposition, configuratorpage, cleanup, product, productlist) never
used the error bag and are plain `KenedoModel`s now.

Untouched on purpose, because they are separate error bags rather than this one:
`ConfigboxJsonResponse::setErrors()`, the `KenedoPlatform` implementations' internal bag, and
`ConfigboxUserHelper::addError()`.

@see [exceptions-and-validation.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/exceptions-and-validation) — the playbook

---

## 2026-07-27 — the `admin` prefix is gone from model class names 🟢

**What changed.** Every `ConfigboxModelAdmin<X>` is now `ConfigboxModel<X>` — file names too
(`models/adminquestions.php` → `models/questions.php`). `ConfigboxModelAdminReviews` folded into the
existing `ConfigboxModelReviews`. Controllers keep their names; this is models only.

**A shim keeps old callers working.** `KenedoModel::getModel('ConfigboxModelAdminQuestions')` (any
casing) is rewritten to the new name and logged once per name to the `deprecated` log, so
customization code and frozen migration scripts run unchanged. Update at leisure; the log tells you
where.

**The one thing to check in custom code: filter names.** Filter keys derive from the model name, so
`'adminquestions.page_id'` is now `'questions.page_id'` — the old key is silently ignored, not
shimmed (filters are data, not classes). Grep your customization for `'admin` followed by a dot-key
and update. Error-message breadcrumbs ("copy of questions ID …") changed the same way.

| Area | Old | New | Status |
|---|---|---|---|
| `getModel('ConfigboxModelAdminX')` | worked | works via shim, logged deprecated | 🟢 update at leisure |
| `class MyModel extends ConfigboxModelAdminX` | worked | class is gone — extend `ConfigboxModelX` | 🔴 rename |
| Filter keys `'adminx.field'` | matched | match nothing | 🔴 rename |
| `x-configbox.model` in schemas | `ConfigboxModelAdminX` | `ConfigboxModelX` | 🟢 regenerate types |

---

## 2026-07-26 — assignment lookups get a real API, and `city_to_country` is corrected 🟡

**What changed.** Every relation in `ConfigboxCacheHelper::getAssignments()` is now also available as a
named method on **`ConfigboxAssignmentsHelper`** (`getProductIdForQuestion()`,
`getProductQuestionIds()`, `getZoneIdsForCountryId()`, …). Core no longer reads the array anywhere; it
loads only the slice it needs and memoises per request instead of materialising the whole catalog.

`getAssignments()` is **unchanged and still supported** — same 24 keys, same `element_*` aliases, still
cached. Migrating is a quality move, not a forced one, and the guards disappear with it: point lookups
return `null` and collections return `array()`, so the undefined-key-becomes-fatal-500 trap goes away.

**The one thing that actually breaks.** `city_to_country` used to be keyed by **county id as well as
city id** — the build wrote each county's country into the city map. Cities and counties are separate
tables with separate auto-increments, so their ids overlap and a city lookup could return a *county's*
country, silently, for whichever ids collided. Counties now populate their own **`county_to_country`**
map and `city_to_country` holds only cities.

| Area | Old | New | Status |
|---|---|---|---|
| Custom code reading any `*_to_*` assignment key | worked | still works, unchanged | 🟢 migrate at leisure |
| Custom code reading `city_to_country` with a **county** id | returned the county's country | returns `null` | 🔴 use `county_to_country` / `getCountryIdForCounty()` |
| Custom code reading `city_to_country` with a **city** id | could return a *county's* country on an id collision | always the city's country | 🟢 the bug is gone |
| Custom code working around that collision | needed | now wrong | 🔴 delete the workaround |
| `shippingmethod_to_zone` via the new helper | one-element array | `getZoneIdForShippingMethod()` returns a scalar | 🟡 only if you migrate |

Full old-key → new-method table, the mechanical transform and a grep pack:
**[assignments-lookups.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/assignments-lookups)**.

---

## 2026-07-20 — migration removes customization FK constraints on retiring core tables 🟡

**What changed.** The CB4 migration scripts now apply an explicit constraint policy to foreign keys that
**customization side tables** (`#__configbox_external_*`, the documented convention) hold on the three
retiring core tables:

- `3.5.2` removes external FKs on `#__configbox_elements` **before** renaming it to
  `#__configbox_questions`.
- `3.5.3` removes external FKs on `#__configbox_xref_element_option` and `#__configbox_options` before
  dropping them. The xref case previously **re-pointed** such constraints to `#__configbox_answers`;
  it now removes them instead — re-creating constraints on schema the core does not own is brittle.

Removed constraints are **not re-created**. Data and id columns are untouched and keep matching the
successor tables (`element_id` → `#__configbox_questions.id`; xref-referencing columns →
`#__configbox_answers.id`, ids identical) — sites that want the integrity back re-add the FK from a
`data/customization/updates/` script. Every removal is logged to `configbox_upgrade_errors.log` with
that instruction.

**Tables outside the `external_` convention are never touched.** Their FKs follow the `3.5.1`/`3.5.2`
renames automatically (InnoDB carries inbound constraints across a `RENAME TABLE`), but an FK on a
table `3.5.3` **drops** makes that `DROP TABLE` fail and freezes the migration — deliberately. The
script logs which constraint blocked it; the site removes/re-points it, then
`configbox:migrate:unblock` + `configbox:migrate`.

| Area | Old | New | Status |
|---|---|---|---|
| `external_*` FK on the xref table | re-pointed to `#__configbox_answers`, rules preserved | **removed**, not re-created | 🟡 re-add from your own update script if wanted |
| `external_*` FK on `#__configbox_options` | dropped | dropped (unchanged) | 🟡 re-link data via the `option_id` breadcrumb (§3.8) |
| `external_*` FK on `#__configbox_elements` | carried across the rename | **removed** before the rename | 🟡 re-add against `#__configbox_questions` if wanted |
| Non-`external_` FK on a retiring table | xref/options: auto-handled; elements: carried | renames: carried (unchanged); **drops: migration freezes until the site fixes it** | 🔴 site's own responsibility |

Details: the "FKs may get removed" contract note in
[answers-collapse-deep-dive.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/answers-collapse-deep-dive) §3.8.

---

## 2026-07-20 — answer creation now refuses an orphan row 🔴

**What changed.** `ConfigboxModelAnswers::store()` rejects an **insert** whose `question_id` is
empty: it logs the reason to `logs/configbox/` and returns false with a user-facing error. Updates are
unaffected, so pre-existing orphaned rows can still be repaired by assigning a question and re-saving.

**Why.** The option/answer collapse shims `ConfigboxModelAdminoptions`,
`ConfigboxModelAdminoptionassignments` and `ConfigboxModelAdminxrefelementoptions` onto the answers model.
That is correct for reads, but it made the pre-CB4 two-step creation idiom (create option → create xref
linking it to the question) silently write **two** answer rows: an orphan with no `question_id`, plus a
real one whose `option_id` points at the orphan. No exception, no log line, on every create. `question_id`
is nullable and has no FK, so the database could not catch it either. This turns that silent data
corruption into a loud, immediate failure.

| Area | Old | New | Status |
|---|---|---|---|
| Two-step create via the shimmed option/xref models | wrote 2 answer rows, silently | insert refused, logged, `store()` returns false | 🔴 rewrite as a single `adminanswers` create with `question_id` set |
| Rollback `$optionModel->delete($optionId)` after a failed two-step create | deleted the option row | that id is an **answer** id — can delete a live answer | 🔴 delete the rollback with the two-step create |
| Insert with `question_id` set (any correct code) | worked | works | 🟢 no action |

Detection query for damage already written, plus the full narrative:
§3.10 of [answers-collapse-deep-dive.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/answers-collapse-deep-dive).

---

## 2026-07-20 — core stops reading its own legacy cache aliases 🟢

**What changed.** Core now reads the canonical assignment-map keys (`question_to_product`,
`page_to_question`) instead of the deprecated `element_to_product` / `page_to_element` aliases it had
drifted onto — in `ConfigboxCacheHelper::getQuestionData()`, `ConfigboxModelQuestions`, and the
question and configurator-page views. The aliases are **still populated**, so nothing customization-side
changes; core reading its own deprecation shims was simply blocking their eventual retirement.

Two of those reads were also **unguarded**. The assignments map contains only *published* questions on
published pages and products, so an unpublished or mid-creation question is legitimately absent — the
undefined-key warning becomes a fatal 500 wherever warnings are exceptions (Magento developer mode). Now
guarded.

| Area | Old | New | Status |
|---|---|---|---|
| Custom code reading `element_to_product` / `page_to_element` | worked | still works (alias) | 🟢 update at leisure |
| Direct `$ass['question_to_product'][$id]` reads in custom code | — | guard with `isset()` — absence is legitimate | 🔴 guard it |

---

## 2026-07 — configurator client-state store 🟢

The configurator page's client state moved from a **write-once jQuery blob** to a **Redux-style store**
(new AMD module `configbox/store`, `assets/javascript/store.js`). Previously the state lived in
`#configurator-data`'s jQuery `.data('json')` and was essentially set once; now a single store owns it and
maintains it through interactions (selection round-trips update `missingProductSelections`, `pricing`,
per-question `selections`, …). **Fully backward-compatible** — nothing to change for most customizations.

| Area | Old | New | Status |
|---|---|---|---|
| Client state owner | write-once jQuery `.data('json')` blob on `#configurator-data` | `configbox/store` (immutable tree, `dispatch`/`getState`/`subscribe`/selectors) | 🟢 no action |
| JS accessors `configurator.getConfiguratorData` / `setConfiguratorDataItem` / `replaceConfiguratorData` / `getQuestionPropValue` / `questionHasProperty` | read/wrote the blob directly | thin **shims** over the store (same signatures/behaviour) | 🟢 keep using them |
| Reading `#configurator-data` **`data-json` attribute** directly | held the live-ish value | holds only the **frozen initial seed**; the store mirrors a fresh snapshot into the element's jQuery `.data('json')` on every change | 🟡 if you read the raw attribute for *current* state, read the store (or `configurator.getConfiguratorData()`) instead |
| New API | — | `configurator.getStore()` + the `configbox/store` module (`actions`, `selectors`, `MERGE_SERVER_RESPONSE`) | 🟢 optional, for new code |

Note: CBX's jQuery is AMD-scoped (`cbj`) — it is **not** a global (`window.jQuery`/`$`/`cbj` are
undefined on the page). Reach the store/jQuery via `cbrequire(['configbox/store'], …)`.

See [whats-new-for-devs.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/whats-new-for-devs).

---

## 2026-07 — element → question rename 🟡

**What changed.** The core configurator entity was called **`element`** in the code, DB and admin MVC but
**`question`** in the UI/frontend. CBX 4 finishes the transition — `element` → `question` **everywhere**:
the DB table `#__configbox_elements` → `#__configbox_questions`, every `*element_id*` and feature column, the
admin model/controller/view classes, the pricing/assignment cache keys, the ionCube rules/calc engine, the
rule/calc condition classes + their stored JSON, and the calc-formula DSL tokens. The entity's **id is
unchanged**, so carts/orders/rules/formulas/translations keep pointing at the right rows — only names changed.

**What broke for customizations.**

| Area | Old | New | Status |
|---|---|---|---|
| DB table | `#__configbox_elements` | `#__configbox_questions` | 🔴 update raw SQL |
| Question-id columns | `element_id`, `element_id_a..d`, `column_element_id`, `row_element_id`, `multielementid` | `question_id`, `question_id_a..d`, `column_question_id`, `row_question_id`, `multiquestionid` | 🔴 update raw SQL |
| Feature columns | `element_custom_1..4`, `element_css_classes`; order-config `element_type`/`element_code`; config `label_element_custom_*`, `pm_*_show_element(price)s` | `question_*` equivalents | 🔴 update raw SQL / property reads |
| Model class | `ConfigboxModelAdminelements` | `ConfigboxModelQuestions` | 🟢 `getModel()` aliases old→new |
| Admin controller/views | `adminelements`/`adminelement` (+ editor sub-views `*_elementattribute`) | `adminquestions`/`adminquestion` (`*_questionproperty`) | 🔴 update `controller=`/`view=`/`getView()` |
| Answer→question join prop | `element_id` (join `adminelements`), `joinedby_element_id_to_adminelements_*` | `question_id` (join `adminquestions`), `joinedby_question_id_to_adminquestions_*` | 🔴 update filter/prop names |
| `model_property_customization/` file | `adminelements.php` | still loads onto the renamed model | 🟢 `getLegacyCustomizationAliases()=['adminelements']` |
| Pricing API | `ConfigboxPrices::getElementPrice/…Recurring/getElementWeight` | `getQuestionPrice/…Recurring/getQuestionWeight` | 🟢 legacy aliases kept |
| Assignment cache keys | `element_to_product`, `product_to_element`, `element_to_page`, `page_to_element`, `answer_to_element`, `element_to_answer` | `question_to_*` / `*_to_question` | 🟢 old keys kept as aliases |
| Pricing cache keys | `calcModelByElement`(+Recurring/Weight), `taxClassIdByElement`(+Recurring), `regardingElement` | `*ByQuestion`, `taxClassIdByQuestion*`, `regardingQuestion` | 🟢 old keys kept as aliases |
| Rule/calc classes | `ConfigboxConditionElementAttribute`, `ConfigboxCalcTermElementAttribute` | `…QuestionProperty` | 🔴 rename a custom subclass/ref |
| Rule/calc JSON | `"type":"ElementAttribute"`, `"elementId"` | `"type":"QuestionProperty"`, `"questionId"` | 🟢 migrated in stored data (incl. custom rule columns) |
| Calc DSL tokens | `ElementAttribute(`, `ElementEntry(`, `ElementPrice(`, `ElementPriceRecurring(`, `RegardingElement(` | `QuestionProperty(`, `QuestionSelection(`, `QuestionPrice(`, `QuestionPriceRecurring(`, `RegardingQuestion(` | 🟡 stock migrated; **hard rename, no alias** — external calc code you owe |

**Not touched** (do not rename): Joomla core `#__extensions.element` / `#__updates.element`; WordPress WPML
`$translation->element_id` in `helpers/wordpress.php`.

**Playbook:** [element-question-rename.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/element-question-rename) — full before → after tables (DB /
model / views / pricing / caches / engine / DSL), the per-file decision procedure, worked conversions, and
the shim inventory. **The DB migration ships in the core** (`helpers/updates/3.5.2.php`, applied by
`configbox:migrate`) and does not touch `data/customization/`.

**Core shims that do the auto-conversion** (verify these):
- `KenedoModel::getModel()` — aliases `ConfigboxModelAdminelements` → `ConfigboxModelQuestions` (logs a
  legacy call).
- `ConfigboxModelQuestions::getLegacyCustomizationAliases()` returns `['adminelements']` — keeps loading
  your `model_property_customization/adminelements.php`.
- `ConfigboxCacheHelper` assignment build — `$legacyAssignmentKeyAliases` + `$legacyPricingKeyAliases`
  re-expose the old `element_*` / `*ByElement` / `regardingElement` cache keys as aliases.
- `ConfigboxPrices::getElementPrice()` / `getElementPriceRecurring()` / `getElementWeight()` — `@deprecated`
  aliases of the `getQuestion*` methods.
- **No shim** for the rule/calc `type` strings or the calc DSL tokens — those are a hard rename; the migration
  rewrites the stored data instead. The stored-rules migration is **model-driven**, so a customization's own
  `rule`-typed column is converted too; calc/rule JSON you store in a table the core can't discover is a data
  migration **you owe** (`data/customization/updates/`).

---

## 2026-07 — Admin light / dark / auto theming 🟢

**What changed.** The CBX **admin** area now supports light / dark / auto colour schemes. The
deciding anchor is the standard **`data-bs-theme="light|dark"`** attribute (CBX's namespaced
Bootstrap 5.3 colour-mode system, scoped to `.cb-content`). On Joomla the Atum template already sets it on
`<html>` and CBX inherits it; on other platforms a new `KenedoPlatform::getAdminColorScheme()`
resolves the host preference and CBX stamps the attribute, with `prefers-color-scheme` as the
auto/OS fallback. Every hardcoded colour in the admin CSS was replaced with `--cb-*` custom properties
defined in the new `assets/css/admin-theme.css` (light defaults + a dark override + the auto block).

**What broke for customizations.** Nothing breaks functionally — light mode is pixel-identical, so
existing custom CSS keeps rendering. But custom admin CSS/templates will **look wrong in dark mode** until
they adopt the tokens:

| Area | Old | New | Status |
|---|---|---|---|
| Admin colours | hardcoded hex in `admin.css` / `general.css` / editors / `properties/*.css` | `var(--cb-*)` tokens in `assets/css/admin-theme.css` | 🟢 light unchanged |
| Your `custom.css` (admin) | hardcoded hex | should reference `--cb-*` tokens so it flips | 🟡 works in light; **hardcoded colours won't flip in dark** |
| Third-party admin widgets (Tom Select, jQuery UI) you skin | hardcoded light colours | core ships dark overrides scoped to `[data-bs-theme="dark"] .cb-content` | 🟡 re-check any widget CSS you added |
| New platform method | — | `KenedoPlatform::getAdminColorScheme()` (interface + all 5 impls) | 🔴 a custom `KenedoPlatform` subclass must implement it |

**What to do:** in your admin `custom.css`, replace hardcoded colours with the `--cb-*` tokens (see the full
token list in `assets/css/admin-theme.css`) so your styling flips with the scheme; for any admin colour
that must differ in dark, add a `[data-bs-theme="dark"] .cb-content <your-selector> { … }` rule. Frontend
(configurator/cart) and PDF CSS are intentionally light-only — leave them. **No dedicated playbook** (it's
a token-adoption task, not a structural break); the tokens are self-documented in `admin-theme.css`.

**Core mechanism:** `assets/css/admin-theme.css` (the token palette + dark/auto blocks + vendor overrides),
`KenedoView::getStyleSheetUrls()` (loads it first on admin), `KenedoView::getViewAttributes()` (stamps
`data-bs-theme` off-Joomla), `KenedoPlatform::getAdminColorScheme()` (per-platform scheme resolution).

---

## 2026-07 — Answer/option model collapse 🟡

**What changed.** The two-table answer model was collapsed into one. The former "global option"
(`#__configbox_options`) and the per-question "answer/xref" (`#__configbox_xref_element_option`) became a
single **`#__configbox_answers`** table with a clean 1:n relationship to questions. The answer's identity
is its own id (unchanged from the old xref id), so carts, orders and rules kept working.

**Migration status: ships and runs automatically.** `helpers/updates/3.5.3.php` performs the whole collapse
(schema, data, `#__configbox_strings` + frozen `cbcheckout_order_strings` re-key, the
`cbcheckout_order_configurations.xref_id → answer_id` rename, dropping the two old tables) during the
package install/upgrade; `3.6.2.php` backfills the `answers.option_id` breadcrumb on databases that were
collapsed before that column existed. **Your customization's own tables are the exception** — any FK into
`#__configbox_options` is dropped and the rows left untouched for you to re-link via `answers.option_id`.
See §3.8 of [answers-collapse-deep-dive.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/answers-collapse-deep-dive).

**What broke for customizations.**

| Area | Old | New | Status |
|---|---|---|---|
| Models | `ConfigboxModelAdminoptions`, `ConfigboxModelAdminoptionassignments`, `ConfigboxModelAdminxrefelementoptions` | `ConfigboxModelAnswers` | 🟢 `getModel()` aliases old→new |
| Admin controllers/views | `adminoptions`, `adminoptionassignments`, `adminxrefelementoptions` (+ singular) | `adminanswers` / `adminanswer` | 🔴 update `controller=`/`view=` URLs & links |
| DB tables | `#__configbox_options`, `#__configbox_xref_element_option` | `#__configbox_answers` | 🔴 update raw SQL |
| Answer title/description strings | `#__configbox_strings` keyed by `option_id` (types 5/15/60/61) | keyed by the **answer id** | 🔴 update `getTranslation()` keys |
| The `option_id` join ("Reused Answer") | property on the answer model | **removed** (1:n, no shared option) | 🔴 remove references |
| `model_property_customization/` files | `adminoptions.php`, `adminoptionassignments.php`, `adminxrefelementoptions.php` | merged onto `adminanswers` | 🟡 auto-loaded; see playbook |
| External storage key | `foreignTableKey => 'option_id'` | `=> 'answer_id'` | 🟡 remapped in code; table needs an `answer_id` column + data re-key — join `#__configbox_answers`.`option_id` to do it |
| Cache keys | `*ByXref`, `xref_to_*` / `*_to_xref` assignment maps | `*ByAnswer`, `answer_to_*` / `*_to_answer` | 🔴 update if you read the pricing/assignment caches |
| Pricing API | `ConfigboxPrices::getXref*()` | `ConfigboxPrices::getAnswer*()` | 🔴 rename calls |
| Frozen order config | `cbcheckout_order_configurations.xref_id` + `.option_id` | `.answer_id` (option_id dropped) | 🔴 update if you read order configs |

**Playbook:** [model-property-customizations.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/model-property-customizations) (covers the
`model_property_customization/` files in full). Other kinds (overridden controllers/views referencing the
old names, raw-SQL customizations, cache/price-API consumers) follow the same old→new mapping in the table
above; dedicated playbooks will be added if a real customization needs one.

**Core shims that do the auto-conversion** (so you can verify the behaviour):
- `KenedoModel::getModel()` — aliases the three old model class names to `ConfigboxModelAnswers`
  (same pattern as the older `CbcheckoutModelOrder`→`ConfigboxModelOrderrecord` alias). Logs a legacy call.
- `KenedoModel::getCustomPropertyDefinitions()` + `getLegacyCustomizationAliases()` — the answers model
  also loads the three legacy `model_property_customization` files and merges them, remapping
  `foreignTableKey 'option_id' → 'answer_id'` and dropping the removed `option_id` join. A legacy file that
  errors (references a dropped table/deleted property) is **logged and skipped**, not fataled.
- `ConfigboxModelAnswers::normaliseLegacyCustomPropertyDefinitions()` — the per-model fix-ups.
