---
name: suppa-entity-code
description: Write Suppa 2.0 application schema CODE — entity classes, seeds, and EntityModule registration — for a versioned application package, following the platform's declarative migration rules (the entity class IS the migration). Use whenever the user wants to create entities/tables/seeds as TypeScript code in an app repo instead of via API tools, port a Suppa 1.0 entity to v2 code, or asks about @Entity/@Column/@Enum/@ManyToOne decorators, createSeed, importKeyFields, or EntityModule.forFeature. Trigger phrases (English) — "write entity code", "entity class", "declarative migration", "create seed", "seed file", "register entity module", "port v1 entity to code"; (Ukrainian / Українська) — "написати код сутності", "клас сутності", "міграція", "сід", "seeds", "зареєструвати сутність", "перенести сутність у код".
---

# Suppa v2 Entity Code (declarative migrations)

The entity class **is** the migration — there is no separate migration file to write.
The platform converges every tenant's schema to your class definitions on every application start.
This skill writes `*.entity.ts` files, `*.seed.ts` files, and the `EntityModule.forFeature()` registration that wires them in.

## Which path — API tools or code?

| Situation | Use |
|---|---|
| Live tenant, no app repo — one-off schema change | `suppa_create_entity` / `suppa_add_field` (API tools) |
| Versioned application package — schema ships as code | **this skill** |

Code is authoritative: on every start, entity metadata is **overridden** to match the classes. Seed rows are **insert-only** — rows a user already edited in a tenant survive.

## Pre-flight (hard gate)

Confirm all 5 before writing anything:

1. Target app repo exists, with `src/index.ts` exporting `AppModule`.
2. `@suppa/sdk@latest` is installed (`pnpm add @suppa/sdk@latest`) — and **1.38.0 or newer if the schema declares custom fields** (`@CustomFields` does not exist below it; E129).
3. `tsconfig.json` has both `experimentalDecorators: true` and `emitDecoratorMetadata: true`.
4. Recommended layout is in place (or will be created): `src/common/database/{entities,seeds}`, each with an `index.ts` barrel.
5. A local module — conventionally `src/common/modules.ts` (imported as `'../../modules'` from `src/common/database/seeds/`) — exports `EntityNamesEnum` (one member per entity `name`).

## Blocking questions — ask BEFORE writing any file

| Ask | Get it wrong and… |
|---|---|
| `importKeyFields` — the business key | Seeds have nothing to match on -> duplicate rows every restart |
| `representativeFieldName` | Records display incorrectly wherever the entity is referenced |
| Per-field `nullable` | The wrong constraint ships to every tenant |
| Flow B only — `inverse` selector for `@OneToMany` / `@ManyToManyBackRef` | v1 never reports it — it cannot be inferred, must ask |

## Hard rules

1. Every entity extends `SystemBaseEntity`; never declare `id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `removedBy` yourself — it already provides them.
2. An entity class that is not in `EntityModule.forFeature()` does not exist — the platform creates no table for it (doc: "An entity class that is never passed to forFeature() is invisible"). Write the entity and register it in the SAME step; never move on to the next entity, or report progress, with an unregistered class. Being *named* in a `forFeature()` is not enough: the call's result must be consumed by an `@Module`'s `imports` (rule 21), or nothing is registered at all.
3. `default` is a SQL expression string — `'false'`, `"'draft'"` — never a JS value; a JS value is rejected at migration time.
4. Scalars only via `@Column({ type: FieldTypeEnum.X })`; relations/enums/files are NEVER declared via `@Column` — use the `@ManyToOne`/`@ManyToMany`/`@OneToMany`/`@ManyToManyBackRef`/`@Enum`/`@MultiEnum`/`@File`/`@MultiFile` families.
5. Relation targets are thunks — `() => Target`, never the class itself — or circular imports break.
6. Do not pass the optional `inverse` selector to `@ManyToOne`/`@ManyToMany` — write `@ManyToOne(() => Target, { … })`. Add one only when the target class declares the paired back-reference and you are deliberately linking the two sides. An `inverse` must select a RELATION property on the target (`(task) => task.project`); `(e) => e.id` is always wrong — `id` is a system column, not a back-reference.
7. If an option isn't in the reference tables (`references/entities.md`, `references/seeds.md`, `references/registration.md`), it doesn't exist — stop and ask, don't guess.
8. Every entity class must appear in the class array passed as `forFeature()`'s FIRST argument (there is no `entities` option — rule 21); every seed in the options object's `seeds` array, dependencies first — `workflowsSeed` before `stageWorkflowsSeed`.
9. Seeds: every record carries all `importKeyFields` (or `$key`); relations are referenced by the target's import-key object, never a numeric id; no system columns; no `$readAccess`/`$updateAccess`/`$instanceAccess`; no reverse `@OneToMany` field, even as `[]`.
10. Autoload paths (`entitiesPath`/`seedsPath`) point at `dist/`, never `src/` — autoloading reads compiled output.
11. Renames need `key` set on `@Entity()`/`@Column()` BEFORE the rename ships — otherwise it's a drop-and-create, not a rename.
12. `subType` refines `type` and means three unrelated things depending on the base type — `Timestamp` → `'date' | 'time' | 'timestamp'`, `Text` → `'encrypted'`, `Enum`/`MultiEnum` → the enum's global name. Never pass `subType` by hand on an `@Enum`/`@MultiEnum` field: the decorator fills it from its first argument (or derives `<ClassName>.<propertyName>`), and a hand-written one either duplicates that or silently disagrees with the value that resolves `{ value: 'active' }` on insert. Any other `(type, subType)` pair does not exist.
13. `subType` is **immutable after creation**, with one exception: toggling `'encrypted'` on or off. The Builder API answers `Field subType cannot be changed.` to anything else — so choosing `Timestamp` where you needed `subType: 'date'` is not a conversion you can ship later, it is a NEW field plus a removal of the old one (see `references/registration.md` for what removal leaves behind). Get date-vs-timestamp right the first time. To CLEAR a subtype you must write `subType: null` explicitly — deleting the line does nothing, because an omitted key is dropped from the sync payload and the stored subtype survives.
14. **Every relation property carries its TypeScript type, and the type says what the decorator says.** `@ManyToOne` holds ONE row — `owner: ProductMatrixEntity;` — while `@ManyToMany`, `@OneToMany` and `@ManyToManyBackRef` hold many — `envs: ApplicationEnvsEntity[];`. Type it optional (`owner?: Target`) unless the relation is `nullable: false`. An untyped relation property is `any` to every consumer of the entity — and so is one typed `any`. E114/E115/W610/W615.
15. **Enum member keys are PascalCase** — `NotFilled = 'Not filled'`. The key is the identifier the code reads; the string on the right is what the platform stores, and the two need not match. W611.
16. **Never write an option that already holds its default value.** The platform applies `canGroup: true`, `canSort: true`, `showInTable: true`, `editFromTable: true`, `canFilter: true`, `readOnly: false`, `searchable: false` on its own. Declare one of these ONLY where the field departs from it; repeating defaults on every field buries the one option that actually differs. W612, and it fails the gate.
	**This rule outranks the surrounding code.** An existing app repo very often has entities that restate all seven on every field — that is the habit this rule exists to stop, not a local convention to match. Write the new file clean; leave the old ones alone unless asked. The same goes for the platform's captured API docs: `api-entity-builder-builder.txt:66-73` lists these defaults as `false`, which live tenants contradict — a field created with no options comes back visible, sortable, groupable, filterable and editable.
17. **`@Enum`/`@MultiEnum` take the global name only when the enum is global.** Default is local: `@Enum(StageStatusEnum, StageStatus, { nullable: false })` — the decorator derives `<ClassName>.<propertyName>`. Pass the name as the FIRST argument only when the value set is shared across entities or referenced from a seed: `@Enum('StageWorkflows.status', StageStatusEnum, StageStatus, { … })`. W613, and it fails the gate.
	**This rule overrides the platform doc**, which says flatly "Give the enum a global name (first argument)" (migrations.html → `references/entities.md`) and passes one in every example. Follow the rule, not the example.
	**A field's `key` is not a reference.** `key: '<Entity>.<field>'` on the same field is the rename key (rule 11) and is spelled exactly like a global enum name — it does not make the enum shared, and it is not what a seed resolves against.
18. Repeating rows owned by one parent record are a **tabular part**, never a plain entity: `type: 'tabular-part'` + `relationEntityName` (the parent's `@Entity()` name) + a `@ManyToOne` field named exactly `owner` pointing at the parent class — all three required, the platform infers none of them. Put `owner` FIRST in `importKeyFields` — it scopes the business key to the parent, or rows collide across different parents.
19. **A relation target that ALREADY EXISTS on the tenant gets a STUB — four requirements, none inferred.** When a relation points at an entity this package does not define (owned by another application, or created outside this package), declare a stand-in. All four bind, and each has its own finding:

	| # | Requirement | Why | Broken |
	|---|---|---|---|
	| 1 | `@Entity({ name, key })` on the class | the platform resolves the relation by that name; a bare `export class Accounts {}` satisfies the thunk at compile time and registers NOTHING | **E116** |
	| 2 | `extends SystemBaseEntity` | it contributes `id`/`createdAt`/…, and relation properties typed with the stub are only usable with them | **E101** |
	| 3 | **EMPTY body — no fields, ever** | the stub names a table you do NOT own; every field it declares is a column the migration creates or alters on that real table | **E117** |
	| 4 | Alone in `<kebab-name>.stub.entity.ts`, exported from the barrel, **registered** in `forFeature()` | the filename is the only thing in the code that says "stub", and it is what makes requirement 3 checkable; an unregistered class is invisible to the platform | **W617** (wrong file) / **W616** (sharing a file) / **E301** (unregistered) |

	```ts
	// accounts.stub.entity.ts — one stub, one file
	import { Entity, SystemBaseEntity } from '@suppa/sdk';

	@Entity({ name: 'Accounts', key: 'Accounts' })
	export class Accounts extends SystemBaseEntity {}
	```

	`.stub.entity.ts` still ends in `.entity.ts`, so discovery and `entitiesPath` autoload are unchanged. **Registration is not what protects the real table — the empty body is.** Leaving a stub out of `forFeature()` to "keep the migration away from it" only makes the relation unresolvable (E301); a *fielded* stub that is registered is the actual disaster, and E117 is the rule that stops it. Platform system entities (`Users`, `Files`, `Icons`, …) need no stub at all — import `UsersEntity`/`FilesEntity`/`IconsEntity` from `@suppa/sdk`. Template: `templates/relation-target-stub.template.ts`.

	**"ALREADY EXISTS" is a precondition, not a description — check it.** A stub is still a declaration, so if the entity is NOT on the tenant the platform creates it: an empty table, owned by YOU. The platform stamps `application` on an entity created by your migrations, which is exactly what a stub for a missing entity is. Confirm each target first — `suppa_describe_entity("Accounts")` answers in one call and reports the owning `application` (absent = nobody owns it). An entity that is not there is not a stub case: either it belongs in this package as a real entity, or the relation is pointing at the wrong name.
20. **To ADD columns to an entity you do not own, declare an EXTENSION — a stub cannot do it.** A stub stands in for a table so a relation can point at it, and its body stays empty (rule 19). An extension is the opposite: it exists to contribute columns to an entity the platform or another application owns — `accountId` on `Users`, `plan` on another application's `Tasks` — "without touching that entity or the fields someone else declared on it". There is no separate API: declare `@Entity({ name: 'Users' })` by the entity's existing name, list ONLY the columns you are adding, and register it in `forFeature()` like any other class. The platform decides the mode by ownership at start — yours or nobody's → own mode (create/update in full); someone else's → extension mode (only your fields are applied).
	Because nothing in the code says which mode you will get, **the filename does**: `<entity-name>.extension.entity.ts`, class `<EntityName>Extension`. That is what makes the mode's restrictions checkable before the build:

	| In extension mode | Why | Code |
	|---|---|---|
	| Entity options are IGNORED — `title`, `icon`, `type`, `options`, `representativeFieldName`, `importKeyFields`, localization | "silently and by design — they belong to the owner" | **W618** |
	| No `@OneToMany` / `@ManyToManyBackRef` | "a reverse relation creates a field on the other entity" | **E118** |
	| No `primary: true` | "the owner's primary key is not yours to redefine" | **E119** |
	| No `nullable: false` without a `default` | the table already has rows, and each needs a value | **E120** |
	| `@Index` / `@Check` only over columns YOU declare, and spelled out as a literal array | anything else reaches into another owner's schema — and a column list passed by reference cannot be checked at all | **E121** / **E122** |
	| Every options object written out literally, on `@Entity()` and on each field | the restrictions above are all read out of those objects; one passed by reference or carrying a spread hides `primary`, `nullable` — and which entity this even extends | **E123** |

	Allowed, and unchanged from your own entities: `@Column()` of any type, `@ManyToOne`, `@ManyToMany`, `@Enum`, `@MultiEnum`, `@File`, `@MultiFile`. Your columns are real columns on the owner's table — selectable, filterable, in metadata, in history — and they follow the same remove/restore semantics as your own. You cannot remove a field you do not own; it is not in your declaration to begin with.
	**A name collision fails the build and applies nothing** — `cannot declare field "Tasks.plan": it already exists and is owned by application "Planner"` — by NAME, not by key, and a soft-deleted field keeps its name reserved. A field a user added through the UI is *not* an owner: your declaration takes it over, data and all.
	**Point a relation at an entity you do not own**: the SDK takes NO string target — `@ManyToOne` is typed `() => Target` and *calls* it at registration, so `@ManyToOne('Invoices')` fails tsc and would throw. Name the entity on a raw column: `@Column({ type: RelationTypeEnum.ManyToOne, relationEntityName: 'Invoices', relationFieldName: 'id', nullable: true }) invoice?: { id: number }` — registration reads `relationEntityName` instead of calling a thunk. A string target is W620 everywhere. Template: `templates/entity-extension.template.ts`; the doc's own section is transcribed in `references/extensions.md`.
	**Prefer an entity-scoped enum in an extension.** A global enum name has no owning entity, so two applications using the same name write to the same rows — which is rule 18's default anyway: pass no name.
21. **`EntityModule.forFeature()` must be consumed by an `@Module`, and has exactly two shapes.** It RETURNS a module: put it in `@Module({ imports: [...] })` and export that class from `src/index.ts` as `AppModule` — a bare `EntityModule.forFeature(...);` statement discards the result and registers nothing (E307). The two shapes (`references/registration.md`) are `forFeature([A, B], { seeds: [...] })` — classes FIRST, options second — and `forFeature({ entitiesPath, seedsPath })` for autoloading. The options object accepts only `seeds`, `entitiesPath` and `seedsPath`. `forFeature({ entities: [...] })` looks reasonable and registers NOTHING: the classes are dropped with the unknown option and no table is created for any of them. E306.

22. **Custom fields are declared in code too — and the nested class that holds them is NOT an entity.** 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. Declare those as a plain class of columns, reached through a property on the owner (`references/custom-fields.md`). **Needs `@suppa/sdk` 1.38.0** — "older SDKs have no way to describe custom fields from an application", so below that floor there is no code route and the API tools (`suppa_add_custom_field`, `suppa_apply_custom_fields`) are the answer.

    ```ts
    export class TasksCustomFields {
    	@Column({
    		type: FieldTypeEnum.Numeric,
    		nullable: true,
    		title: { en: 'Budget', uk: 'Бюджет' },
    		options: { customFieldSets: [{ contextField: 'category', contextValue: 'design' }] },
    	})
    	cfBudget?: number;
    }

    @Entity({ name: 'Tasks' })
    export class Tasks extends SystemBaseEntity {
    	@Column({ name: 'category', type: FieldTypeEnum.Text })
    	category?: string;

    	@CustomFields(() => TasksCustomFields)
    	customFields?: TasksCustomFields;
    }
    ```

    Five things bind, and the first is the one this skill's own reflexes get wrong:

    - **The nested class has no `@Entity()`, does not extend `SystemBaseEntity`, and is NEVER passed to `forFeature()`** — register only the owner. Rules 19 and 21 do not reach it: it names no table of its own, and registering it makes the platform build a plain table where the generated `{Entity}CustomFields` belongs, which "would break every custom-field read and write". The table is named after the OWNER, never after the class.
    - **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".
    - **`@CustomFields` implies `options: { customFields: true }`.** Writing that option beside it repeats what the decorator already says (rule 16).
    - **Every column needs `options.customFieldSets`** — `{ contextField, contextValue, required?, readOnly?, defaultValue? }`, one field and one value per set, no compound conditions. `contextValue` is a business key (an enum's stored value, a relation's import key), not an id, which is what makes the declaration portable between workspaces. A column with no set "exists in the metadata but no record shows it" — valid, and almost never what you meant.
    - **Inside the nested class the extension-mode restrictions apply:** `nullable: false` needs a `default`; `primary`, `@OneToMany()` and `@ManyToManyBackRef()` are rejected. Every other house rule still binds there too — relation typing (14), no option at its default (16), local enums unnamed (17), stubs for existing targets (19).

    On an entity you do **not** own, the columns still come from your file — only the switch is out of reach: extension mode ignores entity options, so enable the feature once with `suppa_update_entity_options(entity_name, '{"customFields": true}')` (`POST /core/builder/<Entity>/update-options`). That is what W618 means when it fires on `options`.

    **An extension whose only member is `@CustomFields` is complete** — it declares the columns of the generated table and nothing else, and the gate treats that as declaring plenty. W617 ("adds nothing") fires only on a class that declares neither columns nor `@CustomFields`; on a stub, `@CustomFields` is E117 like any other field, because the columns it names land on a tenant you do not own.

    The gate enforces all of it — E124 (nested class registered, decorated or extending a base class), E125 (property name), E126 (primary / reverse relation), E127 (`nullable: false` without a default), E128 (half a context), E129 (SDK below 1.38.0), E130 (one class shared by two owners), W621 (column with no set), W622 (`contextField` not on the owner — owner mode only; in extension mode the owner's fields are declared elsewhere, so you get the advisory W624 instead of a block), W623 (the option written beside the decorator), W625 (a number as `contextValue` on an enum context field — that is an Enums row id copied off one tenant, not the stored value). Start from `templates/custom-fields.template.ts`.

    Two limits from the API reference: the extension table is capped at `CUSTOM_FIELDS_LIMIT` active custom fields (**default 200** — the cap guards the API, not migrations, so a long port can push a table past it and the API then stops serving it; the MCP tools that add fields report where the table stands), and a set declared for a context value applies to that value's **descendants** when the context field points at a hierarchical entity, so one entry covers a subtree.
23. **A `title` is an object of the platform's 24 language codes, with `en`, and every value is text.** The platform keeps titles in its `Localizations` table, one COLUMN per language — `af ar az bg cs da de el en es et fi fr gd hr hy it ka nl pl ro sq uk zh` — and is exact about the shape on the way in, in four ways the doc's "the platform handles the rest" does not say (read from entity-builder, each reproduced against the gate):

    - `title: 'Customers'` is stored as `{ en: 'Customers' }` and nothing else — every other language shows no label;
    - a key outside the 24 is **dropped silently** (`filterSupportedLocalizations`): `ua` is not Ukrainian, `uk` is; there is no `ru` and no `us`;
    - `{ en: '' }` stores an empty label — only a plain string is checked for emptiness, an object is not;
    - an object that carries `id` is read as a REFERENCE to an existing `Localizations` row, not as translations.

    The map of an `@Enum`/`@MultiEnum` (`{ [Key]: { title } }`) is different only in where the mistake lands: it goes into `Enums.title`, an hstore on the record, where an unknown key is *kept* as a language nothing asks for. Same rule. `en` is required because it is the platform's default wherever it needs one. W626, and it fails the gate; W627 (advisory) points at a title whose language set differs from the rest of the repo — a label one language lacks shows as nothing. The API tools (`suppa_create_entity`, `suppa_add_field`) already expand a title into every tenant language before sending; the gate is what does the same job for code.
24. **Names the platform keeps for itself, and the index it already builds.** Read from entity-builder, the platform's own repository, each one a refusal or a broken migration when ignored:

    - An entity name must not END in a companion-table suffix — `Comments`, `CommentReactions`, `Reactions`, `Favorites`, `Approves`, `Approvers`, `Folders`, `Checklists`, `CustomFields`, `StateHistory`. The platform generates `<Entity><Suffix>` tables when the matching option is switched on and refuses the name outright. **E131.**
    - A field must not be named `parent`, `parents`, `children`, `multiChildren`, `comments`, `reactions`, `favorites`, `approves`, `approvers`, `folders`, `items`, `checklists`, `customFields`, `pinned`, `timeTracking`, `spentTime`, `spentTimeTotal` or `searchVector` — those are generated by entity options or owned by search — nor shaped like a multi-type synthetic column (`f12Type`). The platform's field-name guard answers HTTP 400. **E132.**
    - A relation, enum or file field `owner` is stored in a physical column `ownerId`; a scalar column named `ownerId` beside it claims the same column, and the two silently fought over it until the guard learned to refuse. **E133.**
    - **Never `@Index` a single `@ManyToOne` column or a single `unique` column.** metadata-sync registers an index for every one of them on its own; a declared duplicate produces the same `IndexMetadata` key, the bulk insert during sync fails with `23505`, and the tenant migration rolls back — the database does not come up. Composite indexes that include a relation column (`['owner', 'type']`) are a different key and are fine. **W628, and it fails the gate.**

## Decorator decision table

| You need | Decorator |
|---|---|
| Repeating rows owned by one parent record (checklist items, order lines, attendees) | **Tabular part** — `type: 'tabular-part'` + `relationEntityName` + a `@ManyToOne` field named `owner` on `@Entity()`; see `templates/tabular-part.template.ts` and `references/tabular-parts.md` |
| A field only SOME records show, decided by another field's value (custom fields) | `@CustomFields(() => OwnerCustomFields)` on a property named `customFields`; the columns in a plain nested class with `options.customFieldSets` — rule 22, `templates/custom-fields.template.ts`, `references/custom-fields.md` |
| Scalar column (text/numeric/integer/boolean/timestamp/json/uuid/multi-language/bytea/tsvector/multi-type/icon) | `@Column({ type: FieldTypeEnum.<Member> })` |
| Date-only column (no time part) | `@Column({ type: FieldTypeEnum.Timestamp, subType: 'date' })` — there is **no** `FieldTypeEnum.Date` |
| Time-only column | `@Column({ type: FieldTypeEnum.Timestamp, subType: 'time' })` — there is **no** `FieldTypeEnum.Time` |
| Date **and** time | `@Column({ type: FieldTypeEnum.Timestamp })` — omitted `subType` already means `timestamp` |
| Text encrypted at rest | `@Column({ type: FieldTypeEnum.Text, subType: 'encrypted' })` — Text only; see rule 13 for what it costs |
| Fixed single-select value set, used by this field only | `@Enum(<Field>Enum, <Field>Titles?, options?)` → `field: <Field>Enum;` |
| …the same, but shared across entities or referenced from a seed | `@Enum('<Entity>.<field>', <Field>Enum, <Field>Titles?, options?)` |
| Fixed multi-select value set | `@MultiEnum(...)` — same signatures as `@Enum` |
| Foreign key on this entity | `@ManyToOne(() => Target, options?)` → `prop: Target;` — **no inverse** |
| ADD a column to an entity the platform or another application owns | **Extension** — `@Entity({ name: 'Users' }) export class UsersExtension extends SystemBaseEntity { … your columns only … }` in `users.extension.entity.ts`, registered in `forFeature()`; see `templates/entity-extension.template.ts` (rule 20) |
| Relation to an entity that ALREADY EXISTS on the tenant (another application's, or created outside this package) | **Stub** — `@Entity({ name, key }) export class X extends SystemBaseEntity {}`, EMPTY body, its own file, registered in `forFeature()`; see `templates/relation-target-stub.template.ts` (rule 19). System entities: import `UsersEntity`/`FilesEntity`/… from `@suppa/sdk` instead |
| Pivot table between two entities | `@ManyToMany(() => Target, options?)` → `prop: Target[];` — **no inverse** |
| Reverse side of a `@ManyToMany` | `@ManyToManyBackRef(() => Target, (t) => t.<thisRelationProp>, options?)` → `prop: Target[];` |
| Reverse side of a `@ManyToOne` | `@OneToMany(() => Target, (t) => t.<thisRelationProp>, options?)` → `prop: Target[];` |
| Single attachment | `@File({ title })` |
| Multiple attachments | `@MultiFile({ title })` |

`target` is always a thunk, `() => Target`. The `inverse` selector is REQUIRED on `@OneToMany`/`@ManyToManyBackRef` and OPTIONAL on `@ManyToOne`/`@ManyToMany` — omit it there by default. When you do write one, it must select the property on the TARGET class that holds the other side of this relation (e.g. `(task) => task.project`) — never `.id`, never a scalar column.

**Typing the property (rule 14).** The decorator declares the relation; the TypeScript type declares its shape, and the two must agree:

```ts
@ManyToOne(() => ProductMatrixEntity, {
	name: 'owner',
	key: 'ProductMatrixItem.owner',
	nullable: false,
	title: { en: 'Owner' },
})
owner: ProductMatrixEntity;          // ONE row; no '?' because nullable: false

@ManyToMany(() => ProductMatrixEntity, { name: 'peers', title: { en: 'Peers' } })
peers: ProductMatrixEntity[];        // MANY rows

@OneToMany(() => ApplicationEnvsEntity, (entity) => entity.owner, {
	title: getFieldTitle(EntityNamesEnum.Applications, 'envs'),
})
envs: ApplicationEnvsEntity[];       // MANY rows, inverse REQUIRED

@ManyToManyBackRef(() => ApplicationEnvsEntity, (entity) => entity.owner, {
	title: getFieldTitle(EntityNamesEnum.Applications, 'envs'),
})
envs: ApplicationEnvsEntity[];       // MANY rows, inverse REQUIRED
```

A `@ManyToOne` without `nullable: false` is written `owner?: ProductMatrixEntity` — the column may be empty and callers must handle it.

**Field options (rule 16).** `canGroup`, `canSort`, `showInTable`, `editFromTable` and `canFilter` default to `true`; `readOnly` and `searchable` default to `false`. Write one only to depart from that — `searchable: true` on the field users actually search, and nothing on the rest.

**What `subType: 'encrypted'` costs.** The platform forces the column's `unique`, `searchable`, `canFilter`, `canSort` and `canGroup` to `false` and clears `maxLength`, `minLength` and `mask`, whatever you declared — the ciphertext envelope can't satisfy a plaintext constraint or be read by an index. So an encrypted column cannot be searched, filtered, sorted or grouped: if users need any of that, split the field and keep the searchable part in a separate plaintext column. It is also rejected inside a `MultiType` field and on any non-Text type.

## Naming conventions

| Thing | Convention | Example |
|---|---|---|
| Entity/seed file | kebab-case, plural | `stage-workflows.entity.ts` / `stage-workflows.seed.ts` |
| **Extension file** (rule 20) | kebab-case of the OWNER's entity name + `.extension.entity.ts`; class `<EntityName>Extension` | `users.extension.entity.ts` -> `UsersExtension` |
| **Stub file** (rule 19) | kebab-case + `.stub.entity.ts` — one stub per file; the suffix is what makes the empty-body rule checkable | `accounts.stub.entity.ts` |
| Entity class | PascalCase, plural = `@Entity()`'s `name` | `StageWorkflows` |
| Seed export | camelCase plural + `Seed` | `stageWorkflowsSeed` |
| Enum | PascalCase + `Enum` | `StageStatusEnum` |
| Enum member key | PascalCase — the stored value is the string on the right and need not match | `NotFilled = 'Not filled'` |
| Titles map | PascalCase + `Titles` suffix — this doc's own worked examples drop the suffix (`StageStatus`); either is acceptable if used consistently within a repo | `StageStatusTitles` (or `StageStatus`) |
| Enum global name | `'<EntityName>.<field>'` | `'StageWorkflows.status'` |
| Index name | PascalCase, the platform's own convention across its 58 indexes: `<Entity><Columns>Index`, `Uindex` for a unique one — the doc's `idx_tasks_project` spelling is accepted too | `TaskChecklistItemsOwnerTitleUindex`, `AutomationTriggerTokenIndex` |
| Check name | PascalCase in the same shape, `Check` suffix (the doc's `chk_` spelling is accepted) | `TasksPositiveDurationCheck` |

**Every name is English, and it is a translation — never a transliteration.**
A Ukrainian or Russian label spelled out in Latin letters (`opysPrychyny`,
`dataVyrobnytstva`, `kTSpivbesidKerivnykom`) satisfies every rule above and says
nothing in either language. The identifier is what a developer reads, what SQL
prints, what an index is named after and what the next agent greps for; the
label is where the original wording belongs, and it is kept there in full.

Work from the meaning, not the letters:

1. Ask what the field HOLDS, not how the label sounds. `Опис причини` holds the
   description of a reason.
2. Order it the way English orders it — modifier first, head noun last:
   `reasonDescription`, not `descriptionReason` and certainly not `opysPrychyny`.
   `Дата виробництва` → `productionDate`. `Ринок збуту` → `salesMarket`.
   `К-ть співбесід керівником` → `managerInterviewCount`.
3. Reuse the word the schema already uses for that concept. A tenant with
   `customer` everywhere does not want a `client` on one entity.
4. A proper noun or an external code has no translation — keep it, and add the
   word that says what the field holds: `request1CNumber`, `novaPoshtaTracking`.
   A field named only for a brand says what it belongs to and not what it is.
5. If you cannot translate the label, you do not yet understand the field. Ask.
   Transliterating is how not-understanding gets written into the schema, where
   it costs a migration to take back out.

`suppa_add_field`, `suppa_add_custom_field` and `suppa_create_entity` refuse a
name that is only its label in Latin letters, before anything is sent. The
`name_map` in a v1 migration plan is mechanical — transliteration plus a small
dictionary — so it lists the names it could not translate under
`needs_translation`; those are proposals to replace, not answers to apply.

## Flow A — new entity in an app repo

1. Pre-flight (above) — confirm repo, SDK version, tsconfig, layout.
2. Blocking questions (above) — get answers before writing any file.
3. Write the entity AND wire it in (one step, both files): write `*.entity.ts` from `templates/entity.template.ts` per `references/entities.md`, then immediately export it from the entities barrel and add it to the class array passed as `EntityModule.forFeature()`'s first argument — see "Wiring a new entity in" below. The class does not exist to the platform until this is done.
4. Need default rows? Same deal for the seed: write it from `templates/seed.template.ts` per `references/seeds.md`, export it from the seeds barrel, and add it to the `seeds` array (dependencies first) in the same step. The seed is not done until it is in the `seeds` array.
5. Run the close-out gate (below) — do not report "done" before all 4 steps pass.

## Flow B — port a Suppa 1.0 entity to v2 code

1. `suppa_read_v1_entities` -> find the v1 entity.
2. `suppa_read_v1_entity_props` (+ `suppa_read_v1_enum` for enum fields) -> read its fields.
3. Map every field per `references/v1-mapping.md` — the same authority (`classifyV1Field`) as the API migration path.
4. Never silently drop a field: anything unmapped or unsupported gets `// SKIPPED: <field> — <reason>` inside the class body.
5. Continue with Flow A from the blocking questions onward (steps 2-5 above).
6. Record data migrates separately, via `suppa_migrate_entities_from_v1` — this skill only produces schema code.

## Wiring a new entity in (same step as writing it)

**Locate the registration first.** The entity and seed files are new files written from templates; registration is an edit to an existing module file you have not seen yet, so find it before you start.

```
grep -rn "EntityModule.forFeature" src/
```

Fallback: `src/app/app.module.ts`. If the call you find uses `entitiesPath`/`seedsPath` (autoload) instead of explicit arrays, there is nothing to edit here — the class is picked up from `dist/` — so verify the build output path instead.

Four edits, every time:

1. Export the class from the entities barrel — `src/common/database/entities/index.ts`.
2. Export the seed from the seeds barrel — `src/common/database/seeds/index.ts`.
3. Import the class in the module and add it to the class array — `forFeature()`'s FIRST argument.
4. Add the seed to the `seeds` array, positioned AFTER the seeds of everything it references.

```ts
// before
import { Module } from '@nestjs/common';
import { EntityModule } from '@suppa/sdk';
import { Workflows } from '../common/database/entities';
import { workflowsSeed } from '../common/database/seeds';

@Module({
	imports: [EntityModule.forFeature([Workflows], { seeds: [workflowsSeed] })],
})
export class AppModule {}

// after — new import, new entity, new seed
import { MyNewEntity } from '../common/database/entities';
import { myNewEntitySeed } from '../common/database/seeds';

@Module({
	imports: [
		EntityModule.forFeature([Workflows, MyNewEntity], {
			seeds: [workflowsSeed, myNewEntitySeed],
		}),
	],
})
export class AppModule {}
```

**forFeature() RETURNS a module — something must consume it.** It goes in the `imports` of an `@Module`. Called as a bare statement the returned module is thrown away and NOTHING is registered, while the call still looks exactly like working code (E307). And `src/index.ts` must export `AppModule`: the runner imports your package from there and expects to find it.

**The shape is not negotiable.** There are exactly two (`references/registration.md`): the classes as the FIRST argument with an options object second, or an options object alone that autoloads from `entitiesPath`/`seedsPath`. The options object accepts only `seeds`, `entitiesPath` and `seedsPath` — there is no `entities` option:

```ts
EntityModule.forFeature([A, B], { seeds: [aSeed] });               // correct
EntityModule.forFeature({ entitiesPath: 'dist/…/entities' });      // correct
EntityModule.forFeature({ entities: [A, B], seeds: [aSeed] });     // WRONG — E306
```

The third form looks reasonable and registers nothing: the classes are dropped with the unknown option, and no table is created for any of them.

Self-check, no tooling required:

```
grep -rn "MyNewEntity" src/ | grep -i module
```

If that prints nothing, the entity is not registered.

## Close-out gate (all steps, in order — no "done" before all pass)

```
1. suppa_validate_entity_code(path=<app-root>)                          # passed: true
   (or on the command line, same checker: node <this-skill-dir>/scripts/validate-suppa-schema.mjs <app-root>  # exit 0)
   (installed via npm: node_modules/suppa-mcp-2/skills/suppa-entity-code/scripts/validate-suppa-schema.mjs)
2. npx tsc --noEmit                                                             # in the app repo
3. push, then reload:  POST /core/applications/:appId/reload
4. verify the tenant converged: suppa_describe_entity('<EntityName>')
```

Step 1 exists to catch exactly this: an unregistered class is E301 and the gate fails. Never report done on an unrun gate. **The pass criterion is exit `0` / `passed: true`, not "0 errors"** — a run can print `0 error(s)` and still exit 1 on a rule violation.

`validate-suppa-schema.mjs` exits `0` on a clean run, `1` on violations, `2` on a usage error or a missing path. The gate requires exit `0`.

**Two classes of finding block it.** Errors (`E…`) always do. So do the *rule violations* — `W607`, `W609`, `W610`, `W611`, `W612`, `W613`, `W615`, `W616`, `W617` — the codes that name a HOUSE RULE (rules 14-19) broken on a specific line, because a rule that only warns is a rule that gets ignored (that is exactly how an entity shipped with seven restated defaults on every field). Everything else is advisory and never blocks: `W601`-`W606` and `W608`, `W614`, which report what the checker could not see or could not verify, plus style advice the doc itself does not mandate.

`--no-strict` demotes the rule violations back to warnings. It exists for a legacy repo you are not cleaning up in this pass; it is not for making your own new file pass, and the run says so — demoted findings print as `[rule violation, demoted]` and the summary ends with `NOT a clean run`. `W613` argues from absence ("nothing references this name"), so it only blocks when a DIRECTORY was given: the checker then scans every `.ts` from the application's package root, including services and DTOs it never parses, so a name used anywhere in the repo counts as referenced. On an explicit file list it can only warn.

The validator parses with the TypeScript compiler API, read from the app repo you point it at (a Suppa application compiles with `tsc`, so it has one). If it reports `cannot resolve 'typescript'`, run `npm install` in that repo — or `npm i -D typescript@5` if it genuinely has none. Pin the major: plain `typescript` now installs 7.x, whose main entry exports only version info and carries no compiler API.

## Troubleshooting

| Symptom | Cause | Validator code |
|---|---|---|
| No table is created for an entity | The class was not passed to `EntityModule.forFeature()` | E301 |
| No table is created for ANY entity, and the registration looks right | `forFeature({ entities: [...] })` — an invented shape; the classes go in the FIRST argument and the options object has no `entities` key | E306 |
| Every entity is missing its table, and the registration reads correctly | `forFeature()` called as a bare statement — the module it returns is discarded | E307 — put it in `@Module({ imports: [...] })` and export `AppModule` from `src/index.ts` |
| E301 never fires even though an entity is clearly unregistered | A `forFeature()` names neither a class array nor `entitiesPath`, so membership is unknown and the check is skipped for the whole run | W614 |
| Autoloading finds nothing | `entitiesPath` / `seedsPath` points at `src/` instead of compiled `dist/` | E303 |
| Duplicate seed rows after a restart | Record is missing one or more `importKeyFields` | E205 |
| Seed relation is empty or fails to resolve | Relation referenced by numeric id, or the target seed runs later | E203 + E304 |
| Back reference can't insert | A reverse `@OneToMany` field is present in a seed record, possibly as `[]` | E204 |
| A renamed entity or field appears as a new one | No `key` option was set | W602 |
| `default` value is rejected | A JavaScript value was passed instead of a SQL expression string | E103 |
| Generated relation has an inverse selector that fails to typecheck | An `inverse` was passed to `@ManyToOne`/`@ManyToMany`, where it's optional and usually omitted | W609/E111 — drop the selector, write `@ManyToOne(() => Target, { … })` |
| A relation reads as `any`; nothing catches using it as the wrong shape | The property has no TypeScript type | E114 — write `owner: Target;` (`Target[]` for the to-many decorators) |
| A relation is typed `any` (or a union), so callers get nothing | An annotation that names no class — E114 does not fire, because there IS one | W615 — write the target class; platform entities such as `IconsEntity` are importable from `@suppa/sdk` |
| A to-many relation is used as a single record (or the reverse) | The property's type contradicts the decorator's arity, or names a different class than the thunk | E115 — `@ManyToOne` → `Target`; `@ManyToMany`/`@OneToMany`/`@ManyToManyBackRef` → `Target[]` |
| Callers never null-check a relation that can be empty | A nullable `@ManyToOne` is typed as always present (or a `nullable: false` one as optional) | W610 — `owner?: Target` when nullable, `owner: Target` when `nullable: false` |
| Enum members read as data rather than identifiers | Member keys are not PascalCase | W611 — `NotFilled = 'Not filled'` |
| Every field repeats the same seven option lines | Options were written at their default values — often copied from the repo's older entities | W612 (blocks the gate) — delete them; declare an option only where it departs from the default |
| An enum name is invented for a field nothing else references | The optional global name was passed to `@Enum`/`@MultiEnum` when the enum is local | W613 (blocks the gate on a whole-repo run) — omit the first argument; the decorator derives `<ClassName>.<propertyName>` |
| An unreferenced enum global name passes the gate | The field also carries `key: '<Entity>.<field>'`; before 1.7.0 that identical literal counted as a reference and silenced W613 | fixed in 1.7.0 — a field's own `key`/`subType` is no longer a reference |
| Migration rejects the column, or a date field ships as a full `timestamp` | The `(type, subType)` pair isn't one the doc allows — `Timestamp` takes `'date'`/`'time'`/`'timestamp'`, `Text` takes `'encrypted'`, nothing else | E112 |
| `Encrypted subtype is only allowed on text fields` | `subType: 'encrypted'` on a non-Text column | E112 |
| An enum field resolves to the wrong enum, or its values fail to insert | `subType` was written by hand on `@Enum`/`@MultiEnum` instead of being left to the decorator | E113 |
| `Field subType cannot be changed.` | A subtype change other than the `'encrypted'` toggle — add a NEW field instead of converting | rule 13, not statically checkable |
| A removed `subType` line is still in effect | An omitted key is dropped from the sync payload — clear it with `subType: null` | rule 13, not statically checkable |
| `inverse` points at `id` | `id` is a system column, never a back-reference — an `inverse` must select the RELATION property on the target holding the other side | E111 |
| A re-added field already contains old data | Same name means the same key, so the soft-deleted field was restored with its column — this is a runtime migration outcome, not statically checkable | documented-only, see references/registration.md |
| A removed field is still present after a reload | The removal pass was skipped because the system user could not be resolved | documented-only, see references/registration.md |
| The table has columns such as `title_1730457600000` | Leftovers from removed fields — renaming is how the platform removes a column; nothing cleans them up | documented-only, see references/registration.md |
| A tabular part has no `ownerId` column and its tab stays empty | `relationEntityName` was set, but the `owner` `@ManyToOne` was not declared | E108 |
| A tabular part is missing from the parent's tabs | `type: 'tabular-part'` or `relationEntityName` is missing, so it is listed under `/related` instead | E108 |
| Duplicate tabular-part rows across different parents | `owner` is missing from `importKeyFields`, so the key is matched globally instead of per-parent | E110 |
| A tabular part's rows land on/read from the wrong parent | `owner`'s `@ManyToOne` target resolves to an entity whose `@Entity()` name doesn't match `relationEntityName` | E109 |
| A tabular part's business key isn't scoped to its parent | `owner` is present in `importKeyFields` but not first | W607 |
| A relation targets a bare class (`export class Accounts {}`), or one respelled as `const Accounts = class {}` | Not an `@Entity` class — the thunk compiles but the platform cannot resolve the relation. Declare a stub per rule 19 | E116 |
| Columns must be added to an entity the platform or another application owns | Not a stub — a stub's body is empty. Declare an EXTENSION: `@Entity({ name: '<Owner>' })` + only your columns, in `<owner>.extension.entity.ts` (rule 20) | E117 points here |
| An extension sets `title` / `representativeFieldName` / `importKeyFields` and nothing happens | Extension mode ignores every entity-level option — they belong to the owner | W618 (blocks the gate) |
| The tenant migration fails with `23505 duplicate key value violates unique constraint "IndexMetadataKeyIndex"` and the database does not come up | An `@Index` over a single `@ManyToOne` or `unique` column — metadata-sync already indexes those | W628 (blocks the gate) |
| `cannot declare field "X.parent"` / HTTP 400 from the field-name guard, or an entity name refused at start | A platform-owned field name, a multi-type-shaped name, a `<x>Id` scalar beside relation `<x>`, or a reserved entity suffix | E132 / E133 / E131 |
| A label is blank in the Ukrainian UI, or a translation you wrote never appears | `title` was a plain string (stored as `en` only), used `ua` instead of `uk` (dropped silently), or carried an `id` (read as a reference) | W626 (blocks the gate); W627 for a title whose languages differ from the rest |
| An extension declares `@OneToMany` / `@ManyToManyBackRef` | A reverse relation creates a field on the OTHER entity; declare the owning side here instead | E118 |
| An extension declares `primary: true` | The owner's primary key is not yours to redefine | E119 |
| `nullable: false` on an extension column | The table already has rows; each needs a value, so a `default` is mandatory (and on a big table, ship it nullable and backfill separately) | E120 |
| An extension's `@Index`/`@Check` names a column it does not declare | An extension may only index or constrain its OWN columns | E121 |
| An extension's `@Index`/`@Check` gets its columns from a const, a spread or an options object | The documented shape is `@Index('<name>', ['<column>'])`. A list the checker cannot read leaves E121 unchecked — and it compiles, so it ships | E122 |
| An extension passes options by reference (`@Entity(OPTS)`, `@Column(OPTS)`) or spreads them | Extension mode's whole restriction set is read out of those objects — including which entity is being extended. Inline them | E123 |
| Two entity classes share a name across files | The checker keys entities by class name, so only the first is analyzed and the other is schema code nothing checked. Rename one class — the table's name is the `@Entity({ name })` option and does not change | W619 (blocks the gate) |
| The build stops with `cannot declare field "X.y": it already exists and is owned by application "Z"` | Field names have exactly one owner, matched by NAME (not key) — and a soft-deleted field keeps its name reserved. Nothing is applied. Rename yours, or agree with that team | not statically checkable |
| The build stops with `refusing to build system metadata: this is an application build but applicationId is missing` | The platform is older than extension mode, or the runner does not pass the application id. Update the platform — there is nothing to change in the code | not statically checkable |
| A relation names its target with a string (`@ManyToOne('Invoices', …)`), anywhere | The SDK types the target `() => Target` and calls it at registration — a string fails tsc and would throw. Use a thunk, or `@Column({ relationEntityName: 'Invoices', … })` for an entity you cannot import | W620 (blocks the gate) |
| A stub kept its fields, and the migration altered a table another application owns | The empty body is the whole point of a stub — a fielded one converges columns onto the real table | E117 |
| A field-less entity that is really a stub, in an ordinary `*.entity.ts` | Nothing marks it as a stub, so E117 cannot apply — rename to `<name>.stub.entity.ts` | W617 (blocks the gate) |
| Several stubs piled into one `relation-targets.ts` | Each existing-entity stub lives in its OWN file | W616 (blocks the gate) |
| A stub was left out of `forFeature()` "so the migration can't touch the real table" | A stub is registered like any other entity — its EMPTY body is what protects the real table, not the missing registration | E301 |
| The gate passes, but nobody ran it | Install the guards: `node scripts/install-entity-code-guards.mjs <app-root>` — hooks put the rules in front of an agent before it writes and the gate in front of it after, a pre-commit refusal catches whoever had no hooks, and a CI job catches whatever reached the branch. See `references/enforcement.md` | — |
| Validator warns W606 | Content delivered via spread or reference — options, `default`, `subType`, `importKeyFields`, seed records, enum refs, `$key`, autoload paths, registration arrays, tabular-part `type`/`relationEntityName` — is not statically verifiable; the matching check is skipped. Inline it so the validator can check | W606 |
| Validator warns W608 | No `tsconfig.json` at the given root, so `experimentalDecorators`/`emitDecoratorMetadata` could not be verified — point the validator at the app root | W608 |
