Tenant fields let each tenant decide what its forms collect, without code changes: shipped fields can be switched off or made required, and new fields can be added. This page explains the core service in src/cluster_erp/core/tenant_fields.py and the routes in src/cluster_erp/server/routes/tenant_fields.py. The metadata classes it builds on are listed on metadata model.
In short¶
- An entity opts in by declaring
EntityMeta.tenant_fieldswith one or more storage buckets (src/cluster_erp/core/metadata.py:517-550). 25 metadata declarations do so (counted withgrep -rn "tenant_fields=TenantFieldsMeta"oversrc/cluster_erp/modules). - Three tables hold the tenant's decisions:
core_field_catalog,core_field_definitions, andcore_field_settings(src/cluster_erp/core/tenant_fields.py:168-295). - A tenant field's value lives in the
extraJSONB column of the bucket row, asextra[key](src/cluster_erp/core/tenant_fields.py:16-19,src/cluster_erp/core/metadata.py:142-146). - Clients apply a per-tenant overlay from
GET /api/v1/_overlayon top of the fixed metadata (src/cluster_erp/server/routes/tenant_fields.py:446-456). - The settings screens are generated: the service injects settings entities into the declaring module's metadata (
src/cluster_erp/core/tenant_fields.py:3433-3497).
How it works¶
The three tables¶
| Table | Class | What a row means | Source |
|---|---|---|---|
core_field_catalog |
FieldCatalogEntry |
One concept the tenant collects, defined once: code, labels, type, choices, category, protection class, permission | src/cluster_erp/core/tenant_fields.py:168-201 |
core_field_definitions |
FieldDefinition |
One placement of a concept on an entity bucket: own (stored on the row) or derived (shown read-only from a linked record) |
src/cluster_erp/core/tenant_fields.py:208-266 |
core_field_settings |
FieldSetting |
The tenant's decision on one shipped field: enabled, required override, order, list and filter flags | src/cluster_erp/core/tenant_fields.py:269-292 |
All three derive from BaseModel, so they are deactivated, not deleted, and their unique indexes cover live rows only (src/cluster_erp/core/tenant_fields.py:194-201, src/cluster_erp/core/tenant_fields.py:255-266, src/cluster_erp/core/tenant_fields.py:287-295). Closing a definition sets is_active to false; stored values stay and are shown read-only (src/cluster_erp/core/tenant_fields.py:9-13).
Specs from metadata¶
At start-up the server calls build_specs with every module's metadata (src/cluster_erp/server/app.py:151). The specs record each declaring entity, its buckets, the shipped fields of each bucket, and the foreign-key links between entities (src/cluster_erp/core/tenant_fields.py:300-354, src/cluster_erp/core/tenant_fields.py:451-500). bucket_for_table maps a table name back to an entity and bucket; the change history uses it (src/cluster_erp/core/tenant_fields.py:506-513, src/cluster_erp/core/change_history.py:373).
Values¶
Owning services call TenantFieldService.validate_extra before they store a row. Every key must be a known, open field of the bucket, and every value is coerced by its definition. A closed field keeps its stored value but takes no new value; an unknown key is refused (src/cluster_erp/core/tenant_fields.py:1942-1955). coerce_value handles the eight field types and stores a decimal as a string (src/cluster_erp/core/tenant_fields.py:139-150, src/cluster_erp/core/tenant_fields.py:854-911). Violations raise FIELD_VALUE_INVALID (src/cluster_erp/core/errors.py:61-66).
Overlay and settings screens¶
The flowchart shows how a tenant's decisions reach a client.
flowchart LR meta["Module metadata: tenant_fields buckets"] --> specs["build_specs at start-up"] specs --> inject["with_tenant_field_entities"] inject --> metaapi["GET /api/v1/_meta"] tables["core_field_catalog, definitions, settings"] --> overlay["TenantFieldService.overlay"] specs --> overlay overlay --> overlayapi["GET /api/v1/_overlay"] metaapi --> client["Web and desktop clients"] overlayapi --> client
In prose: the metadata document stays one per process and carries the settings entities tenant_field_setting, tenant_field_definition, and tenant_field_catalog, added to the declaring module (src/cluster_erp/core/tenant_fields.py:3430-3474). A module without a settings section gets one (src/cluster_erp/core/tenant_fields.py:3484-3497). The overlay is computed per request from the tenant's tables: fields to add, shipped fields to prune, and closed fields to show read-only (src/cluster_erp/server/routes/tenant_fields.py:446-456).
Routes¶
The settings routes live under each declaring module, /api/v1/{module}/_fields/..., so the generic list and form engines reach them like any other endpoint (src/cluster_erp/server/routes/tenant_fields.py:1-9, src/cluster_erp/server/routes/tenant_fields.py:42). There are 18 routes: definitions (list, create, get, update, close, reopen), settings (list, get, update), catalog (list, create, get, update, bind, derive, close, reopen), and the overlay (counted from the @router. and @overlay_router. decorators, src/cluster_erp/server/routes/tenant_fields.py:150-456). Each settings route requires a permission named after its entity, for example tenant_field_definition.read (src/cluster_erp/server/routes/tenant_fields.py:155).
Search and history¶
When a field becomes searchable, the service builds a trigram expression index on extra->>'key' concurrently, using the same normalised expression as the text search (src/cluster_erp/core/tenant_fields.py:1681-1690). See search, pagination, and bulk operations. The change history records extra.<key> like a column, redacts keys of protection class 3 and above, and emits TenantFieldValuesChanged after the commit (src/cluster_erp/core/tenant_fields.py:916-922, src/cluster_erp/core/change_history.py:137-151, src/cluster_erp/core/change_history.py:357-386).
Why it is built this way¶
- The module docstring states the design: an entity only declares its buckets in metadata, and everything else is platform work in this module, so modules never import one another (
src/cluster_erp/core/tenant_fields.py:1-31). - The catalog docstring states why concepts are defined once: every placement shares the label, choices, and protection, so the same thing is never two fields with two spellings (
src/cluster_erp/core/tenant_fields.py:169-174).
Rules and boundaries¶
- A field key must match
^[a-z][a-z0-9_]{1,59}$and must not be a shipped column (src/cluster_erp/core/tenant_fields.py:152-153). - Each category has a minimum protection class; a field may be protected more, never less (
src/cluster_erp/core/tenant_fields.py:86-101). - From protection class 3, a field needs a permission gate (
src/cluster_erp/core/tenant_fields.py:154-155). Field-level access is documented on field and row access. - A new field that duplicates an existing concept is refused with
FIELD_DEFINITION_DUPLICATE(src/cluster_erp/core/errors.py:67-70).
Interaction with other parts¶
- Owning services in 15 modules use the service (counted with
grep -rlnforTenantFieldService,validate_extra, ortenant_fields importoversrc/cluster_erp/modules). - Modules declare which shipped fields other code depends on by answering
DescribeFieldConsumers(src/cluster_erp/core/contracts.py:2470). See contracts reference.
Limits and open points¶
- An index build failure is logged, not raised; search on that field works without the index until the next attempt (
src/cluster_erp/core/tenant_fields.py:1684-1687).
Terms introduced¶
| Term | Code identifier | Definition | Turkish term | Source |
|---|---|---|---|---|
| tenant field | FieldDefinition |
A tenant-defined field placed on an entity bucket. | kiracı alanı | src/cluster_erp/core/tenant_fields.py:208 |
| field catalogue | FieldCatalogEntry |
The tenant's list of field concepts, each defined once. | alan kataloğu | src/cluster_erp/core/tenant_fields.py:168 |
| bucket | TenantBucketMeta |
A row type that stores tenant field values in its extra column. |
kova | src/cluster_erp/core/metadata.py:517 |
| overlay | overlay |
The per-tenant changes the clients apply to the fixed metadata. | katman | src/cluster_erp/core/tenant_fields.py:1856 |
| protection class | protection_class |
A level from K0 to K4 that says how sensitive a field's values are. | koruma sınıfı | src/cluster_erp/core/tenant_fields.py:129-137 |
Related pages¶
Verified against commit 92f8051 on 2026-11-13.