# Kenedo property types — overview

> A property type is the class that decides how one field behaves: how it renders, how it reads and writes, what column it is, and what it validates. The type …

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/property_types. Last updated 2026-08-12.

---
A property **type** is the class that decides how one field behaves: how it renders, how it reads
and writes, what column it is, and what it validates. The type is chosen by `'type' => '…'` in a
property definition; everything else in that definition configures it.

This is the overview: the settings **every** type accepts, the mechanisms that are shared across
types, and an index of the types. Each type's own settings are in its own article under
[`property-types/`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/).

### Where this fits

| You want | Read |
|---|---|
| Which type to use, and what it does | **this document** and the [per-type articles](https://docs.configbox.at/docs/4.0-preview/technical/property-types/) |
| The storage keys — `dataType`, `nullable`, `unique`, `maxLength` | [`com_configbox_property_definition_settings.md`](https://docs.configbox.at/docs/4.0-preview/technical/property_definition_settings) |
| How properties drive a model's CRUD | [`com_configbox_kenedo_model.md`](https://docs.configbox.at/docs/4.0-preview/technical/kenedo_model) §3, §7.3 |
| Where properties sit in the framework | [`com_configbox_kenedo_mvc.md`](https://docs.configbox.at/docs/4.0-preview/technical/kenedo_mvc) §4 |
| To **write a new type** | [`../customization/com_configbox_custom_properties.md`](https://docs.configbox.at/docs/4.0-preview/customization/custom_properties) — the full guide, with a worked example |
| To **add a field to a stock model** | [`../customization/com_configbox_extending_stock_models.md`](https://docs.configbox.at/docs/4.0-preview/customization/extending_stock_models) |
| What the definitions generate | [`com_configbox_type_generation.md`](https://docs.configbox.at/docs/4.0-preview/technical/type_generation) · [`com_configbox_mcp_server.md`](https://docs.configbox.at/docs/4.0-preview/technical/mcp_server) |

---

## 1. Settings every type accepts

### Identity and labelling

| Setting | Meaning |
|---|---|
| `name` | **Required.** The property name. Also the record key and, by default, the column name |
| `label` | Shown next to the field and used in validation messages |
| `labelList` | Column heading in the list view, when it should differ from `label` |
| `tooltip` | Help text on the form |
| `hideAdminLabel` | Render the field without its label |
| `apiTitle` | The field's short name **for an API consumer**. Falls back to `label` |
| `apiDescription` | The field's prose **for an API consumer**. Falls back to `tooltip` |

> **Adding or changing a property? Write `apiTitle` and `apiDescription` too.**
> `label` and `tooltip` are written for an operator looking at the form — they assume the
> surrounding screen and are often phrased as questions. The API pair is the same field explained
> to somebody reading a schema with no screen in front of them, and it feeds **four** generated
> artifacts: both JSON Schemas, the PHP record stub's `@property` line (what your IDE shows on
> hover) and the TypeScript declarations. A property added without them documents itself as a bare
> DDL type. See [§9 of the property-definition settings](https://docs.configbox.at/docs/4.0-preview/technical/property_definition_settings)
> for how to write them.

### Placement

| Setting | Meaning |
|---|---|
| `positionForm` | Sort order in the edit form. Properties are ordered by this, not by declaration order |
| `positionList` | Sort order in the list view. **Absent means the field is not in the list at all** |
| `listCellWidth` | CSS width for the list column, e.g. `'90px'` |

### Behaviour in the list

| Setting | Meaning |
|---|---|
| `canSortBy` | Column header becomes a sort control |
| `addSearchBox` | Field participates in the list's search |
| `addDropdownFilter` | Field gets a filter dropdown above the list |
| `makeEditLink` | Cell links to the record's edit form. Needs `component` and `controller` |
| `component` / `controller` | Where `makeEditLink` points |

### Validation and visibility

| Setting | Meaning |
|---|---|
| `required` | The form may not be submitted with this empty. **Not** the same as `nullable` |
| `default` | Value a new record gets; also the column default in generated DDL |
| `invisible` | Field is rendered but hidden — use for values set programmatically that must still persist |
| `appliesWhen` | Conditional relevance — see below |
| `platforms` | Array naming the platforms the property exists on (`array('joomla', 'wordpress')`). **Absent means all platforms.** The one platform filter — `getProperties()` drops excluded properties at runtime; the column still exists everywhere and `store()` fills its declared default on INSERT. See [§11 of the settings doc](https://docs.configbox.at/docs/4.0-preview/technical/property_definition_settings) |
| `options` | Space-separated flags, e.g. `'ALLOW_RAW USE_TEXTAREA'` — see below |

**`invisible` is the key that works.** `'visible' => false` is read by nothing — it was written on 28
properties and silently did nothing until it was corrected. If you want a field to persist without
appearing on the form, `'invisible' => true` is the one.

#### `options` and `optionTags`

`options` is one space-separated string. The constructor splits it into `optionTags`, a map that
property classes and templates read with `isset($optionTags['SOME_FLAG'])`. You write `options`;
everything downstream reads `optionTags`. There is no central registry of flags — each is read by
whichever class cares, so a misspelled flag is silently inert. The ones actually read:

| Flag | Read by | Effect |
|---|---|---|
| `ALLOW_RAW` | base `getDataFromRequest()` | Take the POSTed value unfiltered |
| `ALLOW_HTML` | base `getDataFromRequest()` | Filter as HTML rather than stripping it |
| `USE_TEXTAREA` | `string` template | Render a `<textarea>` instead of an `<input>` |
| `USE_HTMLEDITOR` | `string` / `translatable` templates | Render the platform's WYSIWYG editor |
| `NO_SAVE_FILENAME` | `image`, `file` | Opt **out** of writing the filename to the column. Rarely wanted — see below |
| `FILENAME_TO_RECORD_ID` | `image`, `file` | Name the stored file after the record id |
| `NODELETEFILE` | `image`, `file` | Suppress the "delete file at save" checkbox |

Three flags — `PRESERVE_EXT`, `NOFILTERSAPPLY` and `SKIPDEFAULTFIELD` — were carried by 32 shipped
definitions while being **read by nothing**, in the engine or anywhere else. They have been removed.
If you meet one in a customization it is inert: delete it. A flag that does nothing is worse than no
flag, because the next reader assumes it does something and preserves it.

Persisting the uploaded filename to the property's column is the **default**. It used to be opt-in,
via a `SAVE_FILENAME` tag: a definition that forgot it uploaded the file to the data folder and never
updated the column, so the record pointed at nothing, the form came back empty, and nothing anywhere
reported an error. All ten shipped definitions carried the tag, which is the clearest evidence it was
not really optional. The tag is gone; `NO_SAVE_FILENAME` exists for the rare property that manages its
own column.

#### `appliesWhen`

Makes a property relevant only for certain values of another:

```php
'appliesWhen' => array('question_type' => array('calendar')),
```

The property is then skipped by validation, and by anything asking whether it applies, unless
`question_type` is one of the listed values. This is why a conditionally-required field must **not**
appear in an API's flat `required` list: it is not required for the other cases.

A bare string is accepted and wrapped, so `'appliesWhen' => array('visualization_type' => 'composite')`
is the same as passing a one-element array. `'*'` in the value list means "any value at all, as long
as the other property is not empty" — useful for "this applies once that one has been chosen".

**Negation — a leading `!`.** A should-value written `'!archived'` means "is not archived":

```php
'appliesWhen' => array('status' => '!archived'),               // status is anything but archived
'appliesWhen' => array('status' => array('!archived', '!draft')), // neither archived nor draft
'appliesWhen' => array('parent_id' => '!*'),                   // applies while parent_id is empty
```

Within one key, **plain values are alternatives** (an IN list) and **negated values are exclusions
that all have to hold** — so `array('!a', '!b')` is "neither a nor b", not "not a, or not b".
Mixing the two means "one of the positives, and none of the negatives". `'!*'` is the inverse of
`'*'`: it applies while the other property is still empty. Conditions across different keys are
ANDed, as before.

> **History — this used to be a trap, and old notes still say so.** Until 2026-08-12 the two
> evaluators used **different sigils**: PHP (`KenedoProperty::applies()`) read a leading **`1`**
> as "is not" while the browser (`assets/kenedo/kenedo.js`) read a leading **`!`**, and PHP only
> negated when the value list had exactly one entry. A negated condition therefore behaved one way
> in the form and the other way in validation. Both sides now implement the `!` semantics above,
> and **`1` is no longer a marker of any kind** — `'enabled' => '1'` means "equals 1", which is
> what it always looked like. Nothing in the shipped definitions used the old marker deliberately,
> so no behaviour changed when it was corrected.

The `'*'` wildcard is safe in both forms — both sides implement it, and both read it as "any
non-empty value".

> **One known residual difference.** PHP derives the tested value with `empty()`, so a record value
> of `'0'` arrives as `null` and matches a `''` should-value; the browser compares the raw string,
> where `'0'` does not equal `''`. A condition written `'field' => ''` therefore applies
> server-side but not client-side when the value is `'0'`. Nothing ships such a condition, and it
> predates the negation fix — but do not write `''` or `'0'` as a should-value.

### Storage — see the settings doc

`dataType`, `nullable`, `unique`, `copySuffix`, `maxLength`, `maxFileSizeKb`.

---

## 2. Storage kinds — what a property contributes to a record

Every property class answers `getStorageKind()` with one of three values. It is a fact about the
**type**, not about one field's configuration, so a property definition cannot override it. It is
what type generation, schema drift detection and the generated JSON/MCP schemas key off.

| Kind | Meaning | Types |
|---|---|---|
| `column` | A column on the model's own table | Everything not listed below |
| `derived` | Appears on records, but the values live elsewhere | `translatable`, `multiselect`, `taxclassrates`, `childentries` |
| `layout` | Form chrome — never reaches a record at all | `groupstart`, `groupend`, `note` |

A `derived` property still shows up in reads (its own `getSelectsForGetRecord()` /
`getJoinsForGetRecord()` / `appendDataForGetRecord()` put it there); it just contributes no column
to the base table. A `layout` property contributes nothing anywhere — no select, no column, no
record key, no schema property.

---

## 3. `storeExternally` — putting a property's column in another table

Any property type whose storage kind is `column` can be told its column does not live in the model's
own table:

```php
$propDefs['some_field'] = array(
    'name'              => 'some_field',
    'type'              => 'string',
    'storeExternally'   => true,
    'foreignTableName'  => '#__my_extension_product_data',
    'foreignTableAlias' => 'myext',
    'foreignTableKey'   => 'product_id',
);
```

| Setting | Required when `storeExternally` | Meaning |
|---|---|---|
| `storeExternally` | — | Turns the mechanism on |
| `foreignTableName` | **yes** | The table actually holding the column |
| `foreignTableKey` | **yes** | The column *in that table* pointing back at the base record's key |
| `foreignTableAlias` | **yes** | Alias used in the join, and the prefix for filter names |

### What it changes

**Reading** — a `LEFT JOIN` onto the foreign table on `foreignTableKey = <base table>.<primary key>`,
and the property selects from the foreign alias rather than the model's own table. `getTableAlias()`
returns `foreignTableAlias` instead of the model name, which is also what makes list filtering and
sorting reference the right table.

**Writing** — the property writes itself:

```sql
INSERT INTO `<foreignTableName>`
SET `<column>` = <value>, `<foreignTableKey>` = <base key>
ON DUPLICATE KEY UPDATE `<column>` = <value>
```

then **unsets itself from the data object** so the model's own store does not try to write a column
that is not there. Correspondingly, `getDataKeysForBaseTable()` returns an empty array for such a
property — that method is what the base store and `copy()` use to decide which keys belong to the
base table.

**Deleting** — `KenedoModel::delete()` runs every property's `delete()` before removing the base row,
and the base implementation issues `DELETE FROM <foreignTableName> WHERE <foreignTableKey> = <id>`.
Your row goes with the record.

**Copying** — the base `copy()` re-inserts the value against the new record's key. When the value is
absent it reads `INFORMATION_SCHEMA` for the column's nullability and default rather than guessing:
a NOT NULL column with no definition `default` and no column default is skipped with a warning in
the log instead of failing the copy.

NULL is preserved as SQL `NULL` rather than becoming `''`.

### Which types support it

| Support | Types |
|---|---|
| **Yes, via the base class** — the type does not override `store()` | `string`, `number`, `datetime`, `json`, `dropdown`, `radio`, `boolean`, `published`, `join`, `pseudojoin`, `calculation`, `countryselect`, `stateselect`, `countyselect`, `rule`, `groupPrice`, `calculationOverride`, `id`, `ordering`, `shapediver*`, `paymentmethodparams`, `commerce2providersettings` |
| **Yes, implemented by the type** — it overrides `store()` and branches on the flag itself | `image`, `file` (the *filename* column moves; the files themselves always live under `dirBase`) |
| **Not applicable** — `derived`, no base-table column to move | `translatable`, `multiselect`, `taxclassrates`, `childentries` |
| **Not applicable** — `layout`, no record key at all | `groupstart`, `groupend`, `note` |

A type that overrides `store()`, `copy()` or `delete()` and does **not** call the parent loses the
part of the mechanism that method carries. `image` and `file` call the parent where it matters;
`multiselect` and `translatable` do not, which is harmless because they are `derived` and there is
nothing external to clean up.

### What it is for

Adding fields to a stock entity **without altering a stock table** — the classic customization case.
Your columns live in your own table, keyed by the stock record's id, and the property behaves like
any other from the form's point of view. No shipped property definition uses it; it exists for
customizations, which is also why it is easy to get subtly wrong.

### Things to get right

- **`foreignTableKey` must be UNIQUE** in the foreign table. The write is an upsert and relies on the
  duplicate-key collision; without the constraint you get a second row per save instead of an update.
- **The alias must be unique** among the model's properties, or two external properties will collide
  in the same query. Two properties sharing one foreign table share one alias and one join, which is
  the intended way to add several columns at once.
- **`join` composes correctly.** `KenedoPropertyJoin::getJoinsForGetRecord()` calls the parent first,
  so an externally stored join emits both the external-table join and the referenced-model join.
- **Type generation** reads `getStorageKind()`, which is still `column` — the storage vocabulary
  describes *what kind of value* it is, not *which table*. `--schema-drift` looks for the column in
  the model's own table, so an externally stored property will be reported as missing there. That is
  a known gap in the diagnostic rather than a problem with your definition.

---

## 4. Choosing a type

Work down the questions; the first match is usually right.

| If the value is… | Use |
|---|---|
| free text on one line | [`string`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/string) |
| free text needing a textarea or WYSIWYG | [`string`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/string) with `USE_TEXTAREA` / `USE_HTMLEDITOR` |
| text the shop shows to customers in their language | [`translatable`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/translatable) |
| a number of any kind — count, price, percentage | [`number`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/number) |
| a date | [`datetime`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/datetime) |
| yes/no | [`boolean`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/boolean), or [`published`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/published) for the publish flag |
| one of a fixed, code-known set | [`dropdown`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/dropdown), or [`radio`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/radio) if the set is tiny |
| one of a set that depends on data in a table | [`join`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/join) |
| one of a set a model method computes, keyed by string | [`pseudojoin`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/pseudojoin) |
| several of a set | [`multiselect`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/multiselect) |
| a country, state or county | [`countryselect` / `stateselect` / `countyselect`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/geography-selects) |
| a reference to a calculation | [`calculation`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/calculation) |
| a visibility or pricing rule | [`rule`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/rule) |
| a JSON document a human may inspect | [`json`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/json) |
| an uploaded image | [`image`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/image) |
| any other uploaded file | [`file`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/file) |
| a list of child records edited in place | [`childentries`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/childentries) |
| nothing — it is form structure | [`groupstart` / `groupend`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/group), [`note`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/note) |

Two recurring mistakes the table is meant to prevent:

- **An id is not a number.** Geography selects and joins store integers, but declaring them as
  `number` gives you a text input where a picker belongs.
- **A JSON column is not a string.** `string` HTML-escapes on the way in, which corrupts JSON. Use
  `json`, or declare no property at all if the column is engine-owned.

---

## 5. The types

Each links to its own article.

### Text and numbers
| Type | Stores | Column |
|---|---|---|
| [`string`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/string) | single-line text | `varchar(255)` |
| [`number`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/number) | integer, decimal, money or percent | `int` / `decimal(20,4)` |
| [`datetime`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/datetime) | date and time | `datetime`, nullable |
| [`json`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/json) | a JSON document | `text` |

### Choices
| Type | Stores | Column |
|---|---|---|
| [`dropdown`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/dropdown) | one of a set, as a select | `varchar(50)` |
| [`radio`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/radio) | one of a set, as radio buttons | `varchar(50)` |
| [`boolean`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/boolean) | yes/no | `varchar(1)` |
| [`published`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/published) | the publish flag | `varchar(1)` |
| [`multiselect`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/multiselect) | several values, in an xref table | *derived* |

### References
| Type | Stores | Column |
|---|---|---|
| [`join`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/join) | an integer foreign key | `int unsigned` |
| [`pseudojoin`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/pseudojoin) | a string key from a model method | `varchar(100)` |
| [`calculation`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/calculation) | a calculation reference | `int unsigned`, nullable |
| [`countryselect` / `stateselect` / `countyselect`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/geography-selects) | cascading geography ids | `int unsigned` |
| [`childentries`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/childentries) | nothing — embeds a child list view | *derived* |

### Text with a life of its own
| Type | Stores | Column |
|---|---|---|
| [`translatable`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/translatable) | one text per active language | *derived* — `#__configbox_strings` |
| [`rule`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/rule) | a visibility/pricing rule | `text` |

### Prices
| Type | Stores | Column |
|---|---|---|
| [`groupPrice`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/groupprice) | per-customer-group price overrides | `text` |
| [`calculationOverride`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/calculationoverride) | per-group calculation overrides | `text` |
| [`taxclassrates`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/taxclassrates) | rates per tax class | *derived* |

### Files
| Type | Stores | Column |
|---|---|---|
| [`image`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/image) | an uploaded image, plus generated variants | `varchar(255)` |
| [`file`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/file) | an uploaded file | `varchar(255)` |

### Layout — no column, no record key
| Type | Purpose |
|---|---|
| [`groupstart` / `groupend`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/group) | open and close a collapsible form section |
| [`note`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/note) | static explanatory text |

### Identity and ordering
| Type | Stores | Column |
|---|---|---|
| [`id`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/id) | the primary key | `int unsigned` auto_increment |
| [`ordering`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/ordering) | sort position | `int unsigned` NOT NULL |

### Integrations
| Type | Purpose |
|---|---|
| [`shapedivermodel` and siblings](https://docs.configbox.at/docs/4.0-preview/technical/property-types/shapediver) | ShapeDiver model, parameter and geometry references |
| [`paymentmethodparams`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/paymentmethodparams) | per-connector payment settings |
| `commerce2providersettings` | per-provider Commerce 2 settings |

---

## 6. Writing your own type

The full guide — resolution, every lifecycle hook in call order, a worked `colorpicker` example,
assets, and the deployment checklist — is
[`../customization/com_configbox_custom_properties.md`](https://docs.configbox.at/docs/4.0-preview/customization/custom_properties).
What follows is the short version, and the five things that most often go wrong.

1. `data/customization/properties/<type>.php`, class `KenedoProperty<Type>`.
2. **A template is required**: `data/customization/properties/tmpl/<type>.php`, or override
   `getAdminTemplateFile()` to delegate to another type's, or override `getBodyAdmin()` to render
   directly. Without one the field renders as **nothing** — so nothing is submitted for it, and what
   you see is an insert failing on a NOT NULL column far from the cause. Missing templates now throw.
   [`pseudojoin`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/pseudojoin) is the worked example of this going wrong.
3. **Declare your storage** — `getStorageKind()`, `getDefaultDataType()`, `getDefaultNullable()`.
   See [§7 of the settings doc](https://docs.configbox.at/docs/4.0-preview/technical/property_definition_settings).
4. **Throw from `check()`**, do not return `false` — see
   [`../migration-to-cb4/exceptions-and-validation.md`](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/exceptions-and-validation).
5. **Call the parent** in `store()`, `copy()`, `delete()` and `getJoinsForGetRecord()` unless you
   mean to drop what it does — that is where `storeExternally` lives (§3).

The hooks a type can implement, in the order they run on a save:
`getDataFromRequest()` → `check()` → `prepareForStorage()` → *(model writes the base row)* →
`store()`. On a read: `getSelectsForGetRecord()` + `getJoinsForGetRecord()` → *(query)* →
`appendDataForGetRecord()`. The per-hook detail is
[`../customization/com_configbox_custom_properties.md`](https://docs.configbox.at/docs/4.0-preview/customization/custom_properties) §3.

**Deliver the column in a migration.** A property definition does not create storage — the column or
table it maps to is added by a versioned script under `helpers/updates/`, idempotently. See
[`com_configbox_migrations.md`](https://docs.configbox.at/docs/4.0-preview/technical/migrations).

---

## See also

- [`com_configbox_property_definition_settings.md`](https://docs.configbox.at/docs/4.0-preview/technical/property_definition_settings) —
  the storage keys, and §8 the schema-drift check that reads these definitions
- [`../customization/com_configbox_custom_properties.md`](https://docs.configbox.at/docs/4.0-preview/customization/custom_properties) —
  building a new type
- [`../customization/com_configbox_extending_stock_models.md`](https://docs.configbox.at/docs/4.0-preview/customization/extending_stock_models) —
  adding a field to a shipped model without touching it
- [`com_configbox_kenedo_model.md`](https://docs.configbox.at/docs/4.0-preview/technical/kenedo_model) — how the model loops over
  properties to read, write, copy and delete
- [`com_configbox_type_generation.md`](https://docs.configbox.at/docs/4.0-preview/technical/type_generation) ·
  [`com_configbox_mcp_server.md`](https://docs.configbox.at/docs/4.0-preview/technical/mcp_server) — what these definitions generate
- Key source: `external/kenedo/classes/KenedoProperty.php`, `external/kenedo/properties/`
