# Kenedo MVC — Architecture, Base Tasks & Property-Driven Data

> A comprehensive description of the Kenedo framework that underpins CBX: its MVC request lifecycle, the base controller tasks every controller inherits, and —…

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

---
A comprehensive description of the **Kenedo** framework that underpins CBX: its MVC
request lifecycle, the **base controller tasks** every controller inherits, and — the core idea —
how **Kenedo Properties** declaratively configure a model's data (form, list, SQL, validation,
storage and filtering all derive from one property-definition array).

Paths are relative to `docroot/components/com_configbox/`; the framework lives under
`external/kenedo/`.

---

## 0. What Kenedo is

Kenedo (by Rovexo) is a bespoke, **platform-abstracted MVC** framework. The same application code
runs on Joomla, Magento 1/2, WordPress or standalone; a `KenedoPlatform` singleton adapts DB,
session, user, document, routing and filesystem to the host (auto-detected at runtime). The cast:

| Class | Role |
|-------|------|
| `KenedoController` | Front-controller base; defines the **base tasks** (display/edit/store/delete/…) |
| `KenedoModel` | Data layer; a **property-definition-driven** query builder + CRUD engine |
| `KenedoProperty` (+ `properties/*`) | One class per field *type*; contributes SQL, validation, storage, rendering |
| `KenedoView` (+ `tmpl/*`) | PHP-template views; renders lists/forms by looping a model's properties |
| `KenedoPlatform` | Host abstraction facade (`p()` → the active platform impl) |
| `KRequest`, `KSession`, `KText`, `KLog`, `KLink`, `KenedoObserver` | Request, session, i18n, logging, routing, events |

> This document is the **overview**. Three of the cast members have dedicated deep-dive references:
> **`com_configbox_kenedo_controller.md`** (the front controller — dispatch, tasks, `store()`,
> authorization), **`com_configbox_kenedo_model.md`** (the data layer — property-driven CRUD, `store()`/
> `copy()`, the child tree, the customization seams), and **`com_configbox_kenedo_view.md`** (the view
> layer — CRUD vs multi-purpose views, assets, curryable setters, `.view-<name>` CSS isolation). Read this
> overview first, then descend into whichever layer you're working on.

**Naming conventions** drive autoloading-by-convention:
- Controller class `ConfigboxController<Name>` ↔ file `controllers/<name>.php`.
- Model class `ConfigboxModel<Thing>` ↔ file `models/<thing>.php`.
- View class `ConfigboxView<Name>` ↔ folder `views/<name>/view.html.php` + `tmpl/`.
- Property class `KenedoProperty<Type>` ↔ file `external/kenedo/properties/<type>.php`.

Note the asymmetry on the admin side: **model files and classes lost the `admin` prefix**
(`models/countries.php` / `ConfigboxModelCountries`), while **controllers and views keep it**
(`ConfigboxControllerAdmincountries`, `ConfigboxViewAdmincountries`) — the prefix is what the admin
permission gate keys on (§2.3). A shim in `KenedoModel::getModel()` rewrites old
`ConfigboxModelAdmin*` names with a deprecation log (`KenedoModel.php:215-226`), and the CB4 renames
alias through the same chokepoint (`Adminelements`→`Questions`,
`Adminoptions`/`Adminoptionassignments`/`Adminxrefelementoptions`→`Answers`, `:186-200`), so old
customization code keeps resolving — but write new code against the new names.

Each factory looks in both the **customization folder** and the **system folder**, which is how
white-label overrides work (see §7). The *direction* differs by kind: properties are customization-first
(your file shadows core), while the class loaders — controllers, models, and view classes — are
**core-first** (the customization folder only loads a class with a name core doesn't define, i.e. a *new*
one). To change an *existing* core class you use `system_overrides/`, not a same-named file in
`controllers/`/`models/`/`views/`.

---

## 1. The request lifecycle

Entry file `configbox.php`:
1. `initKenedo('com_configbox')` boots the framework (autoload, platform singleton, error handlers,
   Composer + app helpers).
2. Reads `option` / `controller` / `view` / `task` from the request (`KRequest::getKeyword`).
3. `KenedoController::getControllerClass($component, $controllerName, $viewName)` computes the class
   name: `ucfirst(component-without-com_) . 'Controller' . ucfirst(name)` →
   e.g. `ConfigboxControllerAdmincalculations` (`KenedoController.php:207`).
4. `getController($class)` loads + singletons it (system path → custom path; core-first, `:79`).
5. `ob_start()`, then `$controller->execute($task)` (default task `display`).
6. The buffered output is passed through the `onBeforeRender` observer and handed to
   `KenedoPlatform::p()->renderOutput()`. If the task set a `redirectUrl`, it redirects instead.

`execute($task)` (`KenedoController.php:251`) is a **reflection-guarded dispatcher**: it verifies
the task is an existing **public** method (else logs + throws "Task not found"), then calls
`$this->$task()`. So "a task" is literally a public method on the controller.

---

## 2. The Controller layer — base tasks

> **Deep dive:** this section is a summary. For `KenedoController` in full — the `execute()` reflection
> dispatcher, the `store()` flow step by step, the whole task catalog with per-task response shapes,
> authorization, and how to extend the controller — see **`com_configbox_kenedo_controller.md`**.

Every controller extends `KenedoController` and implements four (often `NULL`-returning) factories:
`getDefaultModel()`, `getDefaultView()`, `getDefaultViewList()`, `getDefaultViewForm()`. The base
class then provides a complete CRUD task set out of the box.

### 2.1 Base task catalog

| Task (method) | What it does |
|---------------|--------------|
| `display()` | Authorizes, gets `getDefaultView()`, sets `$view->listing = true`, wraps & renders the **list**. (`:312`) |
| `edit()` | Authorizes, gets `getDefaultViewForm()`, sets `listing = false`, renders the **edit form**. (`:336`) |
| `store()` | The canonical save (see §2.2). (`:371`) |
| `apply()` | Calls `store()`; the client JS decides to stay on the (now-saved) edit form. (`:680`) |
| `storeAndNew()` | Calls `store()`; JS then opens a blank form. (`:689`) |
| `afterStore($success)` | Empty hook for subclasses to extend save behavior. (`:698`) |
| `getRecord()` / `getRecords()` | JSON reads: one record / a page of records (`{items, total, offset, limit}`, limit clamped 1–200, default 50), projected into API shape via `ConfigboxApiRecord`. (`:715`, `:764`) |
| `delete()` / `ajaxDelete()` | Delete one (`id`) or many (`ids`) via the shared `deleteManyForModel()` pipeline; always JSON, refusals answer `409 RECORD_IN_USE`. `ajaxDelete` is a deprecated alias. (`:836`, `:809`) |
| `copy()` | Deep-clones a record (and child models, rules, calculations); `201 {ids, newId, redirectUrl}`, `404`/`409` refusals. (`:898`) |
| `publish()` / `unpublish()` | Flip a published flag for `id`/`ids`, purge cache, answer `{ids, published}`. (`:1076`, `:1125`) |
| `ajaxPublish()` / `ajaxUnpublish()` | Deprecated aliases for `publish()`/`unpublish()`. (`:1059`, `:1066`) |
| `storeOrdering()` | Persist drag-reordered list positions from a JSON `updates` map; answers `{ordered: n}`. (`:1136`) |
| `cancel()` | Redirect back (to `return` URL or the list). (`:1198`) |
| `save()` / `remove()` / `ajaxRemove()` | Deprecated aliases for `store` / `delete` / `ajaxDelete`. (`:1453`) |

Router helpers (`getUrlSegments`, `getViewNameFromUrlSegments`, `getSegmentMatching`,
`getSegmentParsing`) support Joomla SEF routing.

### 2.2 The `store()` flow (the spine of all saving)
`store()` (`KenedoController.php:371`) reads the request, then runs the **shared store pipeline** —
`ConfigboxEntityApiHelper::runStorePipeline()` (`helpers/entityapi.php:649`), the same call the REST
controller and the MCP tools make:
```
isAuthorized() ─► seedRequestFromExistingRecord()        // updates: "not sent" means "unchanged"
              ─► model.getDataFromRequest()             // build a normalized data object from the request
              ─► runStorePipeline():                    // shared with REST/MCP/in-process callers
                    prepareForStorage ─► isInsert ─► validateData ─► store ─► cache purge
              ─► afterStore($success)                   // controller hook
              ─► 201 (insert) / 200 (update) + {id, wasInsert, record}
```
A validation refusal is a **thrown `KenedoValidationException`**, answered as a **422** problem-details
response with one issue per bad field; a `KenedoSystemException` answers 500 with only a log identifier.
There is **no `redirectUrl`** in the response — post-save navigation is entirely the client's job (the
JS decides from `task` and `wasInsert`). The pre-pipeline step makes partial requests safe: on an update
every field the caller did not send is seeded from the stored record. Saves travel as **`FormData` over
XHR** (`assets/javascript/server.js`, `submitForm()`) — the hidden-iframe submit is gone.

### 2.3 Output modes & authorization
- `wrapViewAndDisplay($view)` (`:1265`) selects wrapping by `KenedoPlatform::p()->getOutputMode()`:
  `view_only` (bare view, used for AJAX), `in_html_doc` (component-embed), or — for `admin*` views —
  wraps the content in the `ConfigboxViewAdmin` chrome (menu + shell).
- `isAuthorized($task)` (`:1220`) is intentionally coarse: any controller whose name starts with
  `admin` requires the `com_configbox.core.manage` permission; everything else returns `true`.
  `abortUnauthorized()` logs and throws a 403. (Fine-grained per-task ACL is *not* implemented.)
- **A CLI SAPI passes the admin gate automatically** (`:1228-1236`, decided 2026-07-31): whoever can
  run `cli/joomla.php` can already read and edit the database directly, so the host's logged-in-admin
  check — which a CLI process can never satisfy — only refused the honest front door. Web requests are
  never CLI; the `core.manage` check is untouched for them.

---

## 3. The Model layer — property-driven CRUD

> **Deep dive:** this section is a summary. For `KenedoModel` in full — the property assembly
> (`getProperties()` and the `model_property_customization/` merge), `getRecord`/`getRecords` and the
> SQL-building style, the `store()` two-phase save, the recursive `copy()` + child tree, delete-safety, and
> the four model customization seams — see **`com_configbox_kenedo_model.md`**.

A `KenedoModel` subclass usually declares only three things explicitly:
`getTableName()`, `getTableKey()`, and **`getPropertyDefinitions()`** (an array of field
descriptors). Everything else — reading, writing, validating, listing, filtering, copying — is
assembled generically by looping over the properties.

### 3.1 From definitions to property objects
`getProperties()` (`KenedoModel.php:1243`):
1. `getPropertyDefinitions()` (the model's own array) +
2. `getCustomPropertyDefinitions()` (merged-in overrides from
   `data/customization/model_property_customization/<model>.php`, `:1169`),
3. `array_merge`d, `uasort`ed by **`positionForm`**,
4. **not** filtered by platform (`:1584`) — every property exists on every host, and the **`platforms`**
   key (`'platforms' => array('joomla', 'wordpress')`; absent means everywhere) says where it *applies*,
   which hides it in the form and drops its `required` (see §4.1),
5. each turned into a `KenedoProperty<Type>` object via `getPropertyObject()` (`:1364`) — which
   resolves the property class file from the customization folder, then `external/kenedo/properties`.

Results are memoized (`memoGetProperties`). Type generation reads definitions rather than this method
(`getPropertiesForAllPlatforms()`, `:1617`), so the committed artifacts describe every platform at once.

### 3.2 Reading: `getRecord()` / `getRecords()`
`getRecord($id, $languageTag)` (`:1616`) builds the SELECT by **asking every property for its
contributions**:
```php
foreach ($props as $prop) {
    $selects = array_merge($selects, $prop->getSelectsForGetRecord());
    $joins   = array_merge($joins,   $prop->getJoinsForGetRecord());
}
// SELECT <selects> FROM <table> AS <ModelName> <joins> WHERE <key> = <id>
$record = $db->loadObject();
foreach ($props as $prop) { $prop->appendDataForGetRecord($record); } // post-load enrichment
```
`getRecords()` (`:1694`) does the same for lists, additionally folding in **filters** (each
property's `getWheres()`), pagination, sort specs, language, and an optional count-only mode.
Records are **not cached** — record memoization was removed 2026-07-26, so every call is a query
(the `forgetRecord`/`forgetRecords` methods survive as documented no-ops; see
`com_configbox_kenedo_model.md` §4.1).

### 3.3 Writing: `store()`
`store($data)` (`:476`) runs in a DB transaction:
1. Build the **base-table row** by asking each property `getDataKeysForBaseTable($data)` (properties
   stored elsewhere return `[]`), then `insertObject()` (insert or update on the primary key).
2. Loop properties and call `$property->store($data)` — each persists its own side data (external
   tables, translations, child rows). A `false` rolls back.
3. Invalidate what the write can stale — `ConfigboxAssignmentsHelper::forgetForTable()` +
   `KenedoPropertyTranslatable::forget()` (`:568-575`; record data itself is never cached) — then
   `afterStore(id, wasInsert)` hook, commit, fire `onAfterStoreRecord` observer.

`getDataFromRequest()`, `prepareForStorage()`, `validateData()` (`:329`–`:429`) likewise just loop
the properties, delegating to each property's `getDataFromRequest()`, `prepareForStorage()`, and
`check()`. `initData()` (`:1476`) builds an empty record honoring `default` and `prefill_<name>`
request values.

### 3.4 Other model operations
`delete()`/`canDelete()` (`:1970`/`:2093`), `publish()` (`:1924`), `storeOrdering()` (`:2126`),
`copy()` (`:649`, recursive deep-clone of base + child models + rules/calcs), `getRecordUsage()`
(reverse-reference reporting). All are generic and driven by the same property set.

---

## 4. Kenedo Properties — how data is configured (the core idea)

> A model's behavior is **declared, not coded**: you write a property-definition array; from it the
> framework generates the edit form, the list grid, the SELECT/JOIN SQL, validation, storage, search
> and filters. This is the single most important concept in Kenedo.

### 4.1 A property definition is an associative array
Example (from `models/calculations.php`):
```php
$propDefs['name'] = array(
    'name'             => 'name',                 // DB column + request key
    'label'            => KText::_('Name'),
    'type'             => 'string',               // ⇒ KenedoPropertyString
    'required'         => 1,
    'makeEditLink'     => true,                    // list cell links to the edit form
    'addSearchBox'     => true,                    // list gets a search box on this column
    'addDropdownFilter'=> true,                    // list gets a dropdown filter
    'canSortBy'        => true,
    'positionList'     => 10,                      // column order in the list
    'positionForm'     => 200,                     // field order in the form
);
$propDefs['product_id'] = array(
    'name'           => 'product_id',
    'type'           => 'join',                     // a relation
    'modelClass'     => 'ConfigboxModelProducts',
    'modelMethod'    => 'getFilterSelectData',
    'propNameKey'    => 'id',                        // FK column
    'propNameDisplay'=> 'title',                     // shown label
    'parent'         => 1,                           // parent in the entity tree (filters children)
    'lockedAfterStore'=> true,
    'required'       => 1,
    'positionForm'   => 300,
);
$propDefs['type'] = array(
    'name' => 'type', 'type' => 'radio', 'default' => 'matrix',
    'choices' => array('matrix'=>..., 'formula'=>..., 'code'=>...),
);
```
Common definition keys: `name`, `type`, `label`, `tooltip`, `required`, `default`, `positionForm`,
`positionList`, `canSortBy`, `addSearchBox`, `addDropdownFilter`, `makeEditLink`, `listCellWidth`,
`choices` (radio/dropdown), `propNameKey`/`propNameDisplay`/`modelClass`/`modelMethod`/`parent`
(join), `options` (space-separated flags → `optionTags`, e.g. `ALLOW_RAW`, `ALLOW_HTML`),
**`platforms`** (an array naming the platforms the property exists on — absent means all;
`getProperties()` drops excluded ones at runtime, `KenedoModel.php:1263-1267`, and `store()` fills
their declared default on INSERT so the column stays valid everywhere — see
`com_configbox_property_definition_settings.md` §11), and the
**external-storage trio** `storeExternally` + `foreignTableName`/`foreignTableAlias`/`foreignTableKey`.
`modernizeOldPropertySettings()` (`KenedoProperty.php:110`) silently upgrades legacy key names
(e.g. `listing`→`positionList`, `filter`→`addDropdownFilter`).

### 4.2 The property lifecycle hooks (what the model loops call)
Each `KenedoProperty` subclass may implement these; the model calls them at the right phase:

| Phase | Hook | Purpose |
|-------|------|---------|
| Read request | `getDataFromRequest(&$data)` | pull this field's value(s) from the request (honors `ALLOW_RAW`/`ALLOW_HTML`) |
| Pre-store | `prepareForStorage(&$data)` | normalize/auto-fill |
| Validate | `check($data)` | required/format checks; **throws** `KenedoValidationException::forProperty($this, $msg)` to refuse |
| Store (base) | `getDataKeysForBaseTable($data)` | which keys go in the model's own table (`[]` if stored elsewhere) |
| Store (side) | `store(&$data)` | persist external/translation/child data, then `unset` it from `$data` |
| Read (list/record) | `getSelectsForGetRecord()`, `getJoinsForGetRecord()` | SQL fragments this field contributes |
| Read (post) | `appendDataForGetRecord(&$record)` | enrich the loaded record (display values, derived data) |
| Filter | `getWheres($filters)`, `getFilterInput()` | list filtering / search |
| Render (form) | `getBodyAdmin()` / `getPropertyFormOutput()` | the form widget HTML (via `properties/tmpl/<type>.php`) |
| Render (list) | `getCellContentInListingTable()`, `getHeaderCellContentInListingTable()` | grid cell + sortable header |
| Copy/delete | `copy()`, `canDelete()`, `delete()` | participate in clone/delete |

So adding a property automatically wires it into **all** of these — the payoff of the design.

### 4.3 External storage (`storeExternally`)
A property whose data lives in a side table sets `storeExternally` + the `foreignTable*` keys.
`store()` then does an `INSERT … ON DUPLICATE KEY UPDATE` into that table
(`KenedoProperty.php:366`) and `getJoinsForGetRecord()` adds the matching `LEFT JOIN`
(`:1151`). This is how 1:1 satellite data attaches without bloating the base table, and it is the
main tool for adding fields to a stock entity without altering a stock table.

> Full treatment — which types support it, what happens on delete and copy, and the constraints the
> side table must satisfy — is **`com_configbox_property_types.md` §3**. Note that `translatable` is
> *not* an example of this: it is a `derived` property with its own EAV storage, and
> `storeExternally` does not apply to it.

### 4.4 The property type catalog (`external/kenedo/properties/`)
~30 types, each `KenedoProperty<Type>` + a `tmpl/<type>.php` render partial. What follows is the
one-paragraph map; **the reference is
[`com_configbox_property_types.md`](https://docs.configbox.at/docs/technical/property_types)**, which carries the shared
settings, the three storage kinds, a "which type do I want" table, and one article per type under
[`property-types/`](https://docs.configbox.at/docs/technical/property-types/).

- **Scalars/UI:** `id`, `string`, `radio`, `dropdown`, `multiselect`, `boolean`, `published`,
  `datetime`, `json`, `ordering`.
- **`translatable`** — i18n: stores/reads text in the polymorphic `#__configbox_strings`
  (`type`,`key`,`language_tag`,`text`) table via per-language joins; the language comes from
  `getRecord($id, $languageTag)`.
- **`join`** — relations. Recursively builds `LEFT JOIN`s up parent chains and exposes related
  columns under the **`joinedby_<prop>_to_<model>_…`** alias convention; `parent`/`propNameKey`/
  `propNameDisplay` configure the FK, the displayed label and the entity tree. This is Kenedo's
  "ORM relations".
- **`file`, `image`** — uploads (with resizing/derivatives for image).
- **Domain-specific composites:** `calculation` & `calculationoverride` (assign/override a pricing
  calculation), `rule` (the conditional-logic JSON; see the rule-engine doc), `childentries`
  (one-to-many child grids), `taxclassrates`, `groupprice`, `paymentmethodparams`,
  `countryselect`/`stateselect`/`countyselect`, and the `shapediver*` family.
- **Form layout:** `groupstart` / `groupend` wrap fields into visual fieldsets, and `note` places
  static text. These are storage kind `layout` — they never reach a record at all.

---

## 5. The View layer (closing the MVC loop)

> **Deep dive:** this section is a summary. For `KenedoView` in full — the CRUD-view vs multi-purpose-view
> conventions, how a view controls the CSS/JS it loads, class-property and curryable-setter conventions,
> and the `.view-<viewname>` CSS-isolation rule — see **`com_configbox_kenedo_view.md`**.

`KenedoView` renders PHP templates and is itself property-aware for lists/forms.

- `getView($class)` resolves the view file custom→system (`KenedoView.php:181`).
- `display()` → `getHtml()` → `renderView()`.
- **Template resolution** (`renderView`, `:459`) tries, in order: the **Joomla template override**
  path → `data/customization/templates/<view>/<tmpl>.php` → the view's own `tmpl/<tmpl>.php`. The
  first match is `include()`d with `$this` bound to the view. (`<tmpl>` defaults to `default`, or
  the `layout` request param.)
- `prepareTemplateVarsForm()` (`:375`) loads the record (`getRecord($id)` or `initData()`) and
  `$this->properties = $model->getProperties()`. The shared **`tmpl/default-form.php`** then
  iterates those properties and emits each one's widget:
  ```php
  foreach ($this->properties as $property) {
      $property->setData($this->record);
      // <div class="kenedo-property ..."> label + $property->getBodyAdmin() </div>
  }
  ```
  So the form is literally a render of the model's property list — same source of truth as the SQL
  and validation. Lists use `default-list.php` / `default-table.php` analogously.
- `getViewAttributes()` (`:906`) emits the wrapper `<div>` with `data-init-calls-once/each` and
  `data-stylesheets`, which the AMD loader (`main.js`) reads to lazily wire up JS modules. Views
  declare their JS via `getJsInitCallsEach()/Once()`.

---

## 6. End-to-end: "add a field" in practice

To add a new column to an admin entity you typically only add **one entry** to the model's
`getPropertyDefinitions()`. From that single declaration you automatically get: the form widget, the
list column (sortable/searchable/filterable per its flags), request parsing, validation, the
SELECT/JOIN SQL, storage (base or external), and participation in copy/delete. No controller, view,
or query code is touched. That is the whole point of the property model.

**The matching DB column must be created through the migration system — never by hand.** A property
definition only maps an *existing* column; the column itself is added by a versioned upgrade script
(`helpers/updates/<version>.php`) so that every install/upgrade gets the schema change
deterministically. Concretely: add the property def to the model **and** add an idempotent
`ALTER TABLE … ADD COLUMN …` script (guarded with `ConfigboxUpdateHelper::tableFieldExists()`) under
`helpers/updates/`. See **`com_configbox_migrations.md`** for how the upgrade/migration system works.

---

## 7. Extending Kenedo in the customization layer

Everything resolves against `getDirCustomization()` = `…/com_configbox/data/customization` (gitignored,
upgrade-safe). Whether your file **shadows** core or only loads as a **new** addition depends on the kind:
the class loaders (controllers, models, views) are **core-first** (new classes only), while templates,
properties, rule conditions and PSP connectors are **customization-first** (they shadow core). To change an
*existing* core class, use `system_overrides/`.

- **Controllers** — `data/customization/controllers/<name>.php` (a **new** controller; core-first, does not
  shadow a core controller — change an existing one via `system_overrides/`).
- **Models** — `data/customization/models/<name>.php` (a **new** model; core-first, same as controllers).
- **View templates** — `data/customization/templates/<view>/<tmpl>.php` (or a Joomla template override).
- **Add/override property defs on a model** — `data/customization/model_property_customization/<model>.php`
  with a function `customPropertyDefinitions<Model>()` returning extra defs.
- **Custom property *types*** — `data/customization/properties/<type>.php` (class `KenedoProperty<Type>`).
- **Custom rule conditions / calc terms / functions / question types** — their respective
  `data/customization/<…>` folders (see the rule- and calculation-engine docs).
- **Event observers** — uploadable via the admin Connectors UI.

The admin **MVC Maker** (`adminmvcmaker`) scaffolds a controller + model + form/list views for a new
custom entity from templates, so a developer can stand up a fully-CRUD-able admin screen by writing
mostly property definitions.

---

## 8. Notable characteristics & caveats

**Strengths**
- A genuinely **DRY, metadata-driven** model: one property array generates form, list, SQL,
  validation, storage and filtering — fast to build admin entities, consistent UX.
- Clean platform abstraction; a coherent customization/override system that survives upgrades.

**Caveats (for any future refactor)**
- **No DI / pervasive statics & singletons** (`getController`/`getModel`/`getView` registries,
  `KenedoPlatform::p()`, `KText`, `KSession`) — almost everything is globally reachable and hard to
  unit-test in isolation.
- **No namespaces / PSR-4**; loading relies on naming conventions + a hand-maintained autoload map.
- **Manual string-built SQL** across the existing code (an opt-in prepared statement layer,
  `KenedoDatabase::setPreparedQuery()`, exists since 2026-08 for new queries); IDs are `intval`-cast and
  most values escaped, but identifiers/sort columns derived from metadata are interpolated more loosely.
  The database layer — `setQuery`/`getQuoted`/`getEscaped` discipline, `setPreparedQuery`, the
  savepoint-emulated transactions — has its own reference: **`com_configbox_kenedo_database.md`**.
- **God classes** (`KenedoModel` ~2.2k LOC, `KenedoProperty` ~1.5k, `KenedoController` ~1.5k) and
  long-lived legacy-compat branches (`MERGELEGACY`, `modernizeOldPropertySettings`,
  `KLog::logLegacyCall`).
- **Coarse authorization** (admin = `core.manage`, no per-task ACL) — and under a **CLI SAPI the admin
  gate passes automatically** (§2.3): shell access already implies DB access, so the CLI is trusted the
  way MCP is. Anything that must not be reachable from a site's shell does not belong behind
  `isAuthorized()` alone.

The realistic modernization anchors are exactly the two best ideas here: the **platform interface**
(swap a PDO/query-builder behind `InterfaceKenedoDatabase`) and the **property-definition metadata**
(keep the declarative model, give properties typed contracts and a DI-resolved registry).
