# Custom fields in code — verbatim rules

Transcribed from docs.modern-expo.com/backend/application-developing/migrations.html, section "Custom Fields" (captured 2026-09-11). The full capture is `docs/superpowers/specs/captures/migrations-custom-fields.txt` in the suppa-mcp-2 repository. If a rule is not here, it does not exist — stop and ask.

**Requires `@suppa/sdk` 1.38.0.** The doc is explicit: "`@CustomFields` and the declarative sets land in 1.38.0. Older SDKs have no way to describe custom fields from an application." Below that floor there is no code route at all — use the API tools (`suppa_add_custom_field`, `suppa_apply_custom_fields`) against the tenant instead. Check the floor before you write the class; a decorator that does not exist in the installed SDK fails at import, not at migration time.

## What they are

> Custom fields are the extra fields a workspace shows **per context** — the fields a task of category `design` has and a task of category `support` does not. The platform keeps them in a generated extension table `{Entity}CustomFields`: one row per owner record, one real typed column per field. **Field sets** describe where each field applies.

This is the distinction that decides whether a field belongs here at all: **a field every record of the entity shows is an ordinary column** and belongs in the entity class. Only a field whose presence depends on another field's value is a custom field.

## The declaration

> An application declares them the way a Nest controller declares a nested body: a class holds the fields, and the owner entity carries that class on a `customFields` property.

```ts
import {
	Column,
	CustomFields,
	Entity,
	FieldTypeEnum,
	SystemBaseEntity,
} from '@suppa/sdk';

export class TasksCustomFields {
	@Column({
		type: FieldTypeEnum.Numeric,
		nullable: true,
		title: { en: 'Budget', uk: 'Бюджет' },
		options: {
			customFieldSets: [
				{ contextField: 'category', contextValue: 'design' },
			],
		},
	})
	cfBudget?: number;
}

@Entity({ name: 'Tasks', importKeyFields: ['shortUID'] })
export class Tasks extends SystemBaseEntity {
	@Column({ name: 'shortUID', type: FieldTypeEnum.Text, nullable: false })
	shortUID: string;

	@Column({ name: 'title', type: FieldTypeEnum.Text, nullable: false })
	title: string;

	@Column({ name: 'category', type: FieldTypeEnum.Text })
	category?: string;

	@CustomFields(() => TasksCustomFields)
	customFields?: TasksCustomFields;
}
```

Three things follow from that one declaration:

| What | How |
|---|---|
| The feature is switched on for `Tasks` | "The `@CustomFields` property implies `options: { customFields: true }`" |
| `TasksCustomFields` becomes the columns of the `TasksCustomFields` table | The nested class is applied to the platform-generated extension table |
| Where each field applies | `options.customFieldSets` on the column |

> And because the property is typed, `customFields.cfBudget` is a real type everywhere the record is read or written.

**Do not write `options: { customFields: true }` next to `@CustomFields`.** The decorator implies it, and an option written at a value something else already sets is the same mistake as writing one at its default (rule 16).

## The nested class is NOT an entity

> Register **only the owner** in `EntityModule.forFeature()`. The nested class is not an entity, has no `@Entity()`, does not extend `SystemBaseEntity` and is never registered — it is reached through the owner's property.

```ts
EntityModule.forFeature([Tasks], { seeds: [] });
```

This is the one place where the skill's usual reflex is wrong. Rule 19 says a class that names a table gets `@Entity()` and registration; rule 21 says a class that never reaches `forFeature()` does not exist. **Neither applies to the nested class** — it names no table of its own, and registering it would make the platform build a plain table where the generated extension table belongs:

> Only the **columns** are applied — no entity row is ever written for `{Entity}CustomFields`. The generated table carries the `owner` relation and the back reference; writing an entity row of your own would replace it with a plain table and break every custom-field read and write.

What the platform creates for you on start: "a unique `owner` relation back to `Tasks`, the standard columns, and a `customFields` back reference on the owner. Re-running is a no-op, and disabling the option later never drops the table or the values in it."

## Two names that are not yours to choose

| Rule | Why |
|---|---|
| "The table is named after the **owner**, never after the class." | `@CustomFields(() => Whatever)` on `Tasks` always applies to `TasksCustomFields`. |
| "The property must be named `customFields`, and one class serves one owner." | "That is the name the platform gives the back reference; both rules fail the build loudly." |

## The fields

> Every property of the nested class is one custom field, declared with the same decorators as any other field — `@Column()`, `@ManyToOne()`, `@ManyToMany()`, `@Enum()`, `@MultiEnum()`, `@File()`, `@MultiFile()`

```ts
export class ServiceProductsCustomFields {
	@Column({
		name: 'articleCode',
		type: FieldTypeEnum.Text,
		nullable: true,
		title: { en: 'Article code' },
	})
	articleCode?: string;

	@ManyToOne(() => Compressors, {
		name: 'compressor',
		nullable: true,
		title: { en: 'Compressor', uk: 'Компресор' },
	})
	compressor?: Compressors;
}
```

Restrictions, and they are the **extension-mode restrictions** — the generated table is not this application's to reshape:

> `nullable: false` needs a `default`; `primary`, `@OneToMany()` and `@ManyToManyBackRef()` are rejected.

Every other house rule still binds inside the nested class: relation properties carry their type (rule 14), no option at its default (rule 16), a local enum takes no global name (rule 17), a relation target that already exists gets a stub (rule 19).

One default worth knowing rather than writing: "Columns default to `initiator: 'client'` — the marker of a *custom* field. Only a client field is reported as `isCustom` by the schema API and offered as a custom field by forms. Pass `initiator` explicitly only to opt out."

## Where each field applies

> A set binds the owner entity to **one context value** — one field, one value, no compound conditions.

```ts
@Column({
	name: 'cfBudget',
	type: FieldTypeEnum.Numeric,
	nullable: true,
	options: {
		customFieldSets: [
			{ contextField: 'category', contextValue: 'design', required: true },
			{ contextField: 'category', contextValue: 'support' },
		],
	},
})
cfBudget?: number;
```

| Key | Meaning |
|---|---|
| `contextField` | "Name of the field **on the owner** whose value decides the context. It must exist on the owner." |
| `contextValue` | "The value that turns the set on" — resolved, see below |
| `required` | "The field must be filled on a record of this context. Default `false`." |
| `readOnly` | "Per-set override of the field's own `readOnly`. `null` (default) defers to the field." |
| `defaultValue` | "Per-set default. `null` (default) defers to the field." |

`contextValue` is written as a business key, not an id:

| Context field type | What `contextValue` may say | Resolved to |
|---|---|---|
| text / numeric / boolean | the value itself (`'design'`) | the value itself |
| `@Enum` / `@MultiEnum` | the stored enum value (`'Refrigeration equipment'`) | the id of that `Enums` row |
| `@ManyToOne` / `@ManyToMany` | the target's import key, its representative value, or the row id | the id of that row |

> That resolution is what makes a declaration portable: ids differ from workspace to workspace, business keys do not. A relation target with neither `importKeyFields` nor a representative field cannot be matched by name — declare the row id there, or give the entity an import key.

Sets are shared: "two fields declaring the same `(contextField, contextValue)` land in the same set, and a set a workspace administrator already created by hand is reused, not duplicated."

**A field with no set never shows up.** A column declared without `customFieldSets` "exists in the metadata (and in `GET /core/schema/Tasks` with an empty `setIds`) but no record shows it. That is a valid intermediate state — an administrator can create the sets in the workspace — but it is rarely what a migration wants."

## An entity you do not own

> [Extension mode](./extensions.md) ignores entity-level options, so an application cannot switch the feature on for an entity that belongs to the platform, to another application, or to the workspace itself — the `@CustomFields` property still applies its columns, but the extension table has to exist. Enable the feature once per workspace through the API (or ask an administrator to):

```http
POST /core/builder/ServiceProducts/update-options
{ "customFields": true }
```

Read that carefully, because half of it is easy to miss: **the columns still come from your file.** Only the switch is out of reach. With the Suppa MCP server the same call is `suppa_update_entity_options(entity_name, options_json)`; it is a schema change (it creates the `{Entity}CustomFields` side table), so record it in the plan beside the code that depends on it. This is what W618 is telling you when it fires on `options` in an extension.

## What happens on start

1. "The owner entity converges — and the `customFields` option implied by the property creates `{Entity}CustomFields` with the `owner` relation if it does not exist yet."
2. "The columns of the nested class are created on that table as client fields, with their titles."
3. "`options.customFieldSets` is materialized: each set is found or created, each link is created or brought in line with the declaration."

> All three steps are idempotent, so restarts and redeploys converge instead of duplicating. Steps 2 and 3 run inside the write transaction: a context that cannot be resolved fails the migration instead of leaving half a declaration behind.

**First boot of a brand-new owner.** "When the owner entity, the option and the custom fields all arrive in the very same first import, the column of a custom field can lose a race against the DDL that creates the extension table (`deadlock detected`). The next start applies it — nothing is lost and nothing needs fixing by hand, but do not read that first-boot error as a broken declaration."

## Reading and writing values

Values ride the owner record under the same `customFields` key:

```ts
await manager.insertOne('Tasks', {
	title: 'Landing page',
	category: 'design',
	customFields: { cfBudget: 500 },
});

const [task] = await manager.select('Tasks', {
	fields: { id: true, customFields: { cfBudget: true } },
	conditions: {
		operator: 'and',
		filters: [
			{ field: 'customFields.cfBudget', comparator: '>', value: 100 },
		],
	},
});
```

> Only the keys present in `customFields` are written; the write guard rejects a value whose field is not in a set active for that record, and enforces `required`.

## Limits worth knowing

- **"A link is never removed automatically."** Dropping a `customFieldSets` entry stops the platform re-creating it, "but the existing link stays — a workspace may have curated it. Remove it in the workspace (or through `CustomFieldSetFields`) when you mean it."
- **"Removing a property follows the normal rules."** It soft-deletes the field and renames the column; re-declaring restores the row with its data. "A field a workspace administrator removed is not resurrected by the next start."
- **"A field a user created through the UI is not a collision."** The declaration takes it over, keeping the column and the data.
- **"The per-table custom field cap guards the API, not migrations."** A declaration can push a table past it; watch the column count.

## Porting a list of existing custom fields

> Turn it into one nested class per owner entity, one property per field, with the contexts of that field in `options.customFieldSets` — and keep the source file in the repository next to it: the class is the migration, the dump is the provenance.

For a large port the platform ships a generator rather than asking you to type it: `scripts/custom-fields-json-to-class.ts` in the `modern-expo` application (git.modern-expo.com/suppa/applications/modern-expo) turns a `{ entity, context_field, fields[] }` dump into the class and "reports the fields that still need a decision (enum value lists, unresolved relation targets)".

That dump shape is exactly the declaration `suppa_apply_custom_fields` reads, so a file written for the MCP tool ports to the decorator without being retyped — and vice versa.

## What the API reference adds

The migrations page links to a separate [custom fields API reference](https://docs.modern-expo.com/backend/api/entity-builder/custom-fields.html) for "the full contract — projections, search, per-set attributes". Captured as `docs/superpowers/specs/captures/api-custom-fields.txt`. Six things there change how you write the class:

- **There is a per-table cap.** Active client fields per extension table are limited by `CUSTOM_FIELDS_LIMIT`, **default 200**; exceeding it answers 400 with the usage numbers. The migrations page says the cap "guards the API, not migrations" — a declaration can push a table past it, and then the API stops serving that table. Count the properties before porting a large list.
- **Sets are inherited down a hierarchy.** "When the context field relates to a hierarchical entity with `parents`, sets also apply to descendants of the `contextValue`." So a field declared for a parent context shows on records of its children; you do not declare one entry per descendant.
- **A field in several sets merges its per-set attributes**, and the rules are not "last wins": `required` — any set demanding it wins; `readOnly` — an explicit editable beats a read-only, `null` defers to the field; `defaultValue` — the first non-null in set id order.
- **The owner's schema already lists the sets.** `GET /core/schema/<E>` carries a `customFieldSets` array (`id`, `title`, `contextFieldName`, `contextValue`) beside `customFields` with their `setIds` — the cheap way to see every context at once. It is schema only; the ACTIVE set for a given record still comes from `/custom-fields/<E>/resolve`.
- **You can sort and filter through a relation custom field** — `customFields.reviewer.fullName` — because each step of the chain yields at most one row. A multi-row collection in the chain is `400 query.planner_unsupported`. Full-text search covers the extension table's own searchable TEXT fields only (not a relation's content), and the base entity must have at least one searchable field of its own.
- **Write values inline on the owner, not on the extension table.** Both paths validate against the active sets, but a direct extension-table write "bridges into an owner update after the values row is written" and is **not atomic** — the values commit first. Inline writes run inside the owner's transaction, bump `updatedAt`, and fire triggers, socket events and automations with `customFields` among the changed fields.

## What the gate checks

| Code | Rule it enforces |
|---|---|
| E124 | the nested class is passed to `forFeature()`, carries `@Entity()`, or extends a base class |
| E125 | the property is not named `customFields` |
| E126 | `primary`, `@OneToMany()` or `@ManyToManyBackRef()` on a custom-field column |
| E127 | `nullable: false` with no `default` |
| E128 | a `customFieldSets` entry without `contextField` or `contextValue` |
| E129 | `@suppa/sdk` pinned below 1.38.0 in `package.json` |
| E130 | one class named by `@CustomFields` on two owners |
| W621 | a column with no `customFieldSets` — no record shows it |
| W622 | `contextField` names no field on the owner — **owner mode only**, where the file declares the whole entity. Inherited `SystemBaseEntity` columns (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `removedBy`) count as fields on the owner and never trip it |
| W623 | `options: { customFields: true }` written beside the decorator that implies it |
| W624 | advisory: the nested class is declared in another file, the SDK range cannot be read, or the class is an **extension** — there the owner's own fields are declared elsewhere, so `contextField` cannot be checked against them and W622 would be guesswork. Reported once per class, and never blocks |
| W625 | a number as `contextValue` on an `@Enum` context field — an id, where the stored value belongs |

## Which route to take

| Situation | Route |
|---|---|
| You ship an application and the SDK is ≥ 1.38.0 | The decorator. The platform converges every tenant on start and the property is typed. |
| SDK below 1.38.0 | `suppa_add_custom_field` / `suppa_apply_custom_fields` against the tenant. |
| No application is deployed to this tenant | The API tools. |
| The owner belongs to another application | Both: the columns come from your file, but the option is enabled once through `suppa_update_entity_options`. |
