# Extending an entity you do not own — verbatim rules

Transcribed from docs.modern-expo.com/backend/application-developing/migrations.html, section "Extending an Entity You Do Not Own" (captured 2026-08-18). The full capture is `docs/superpowers/specs/captures/migrations-extending-an-entity.txt` in the suppa-mcp-2 repository. If a rule is not here, it does not exist — stop and ask.

## What it is

> Your application can add its own columns to an entity that belongs to the platform or to another application — `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 the entity by its existing name and list only the columns you are adding:

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

@Entity({ name: 'Users' })
export class UsersExtension extends SystemBaseEntity {
	@Column({
		type: FieldTypeEnum.Numeric,
		nullable: true,
		title: { en: 'Account', uk: 'Акаунт' },
	})
	accountId?: number;
}
```

Register it in `EntityModule.forFeature()` like any other entity. On start the platform looks up who owns `Users`; because it is not yours, the declaration is applied in **extension mode** — only `accountId` is created, recorded as belonging to your application.

## Own mode and extension mode

The mode is decided **per declared entity, by ownership**, not by anything you write:

| Situation | Mode | Result |
|---|---|---|
| No entity with that name exists | own | The entity is created and belongs to your application |
| The entity exists and is already yours | own | Full update: title, options, icon, fields — everything converges as usual |
| The entity exists and belongs to the platform or another application | extension | Only your new fields are applied; the entity itself is left exactly as its owner declared it |

In extension mode the entity-level options of your declaration are **ignored, silently and by design** — they belong to the owner: `title`, `icon`, `type`, `options`, `representativeFieldName`, `importKeyFields`, and the entity's localization.

Everything else works as it does for your own entities. Your extension columns are real columns on the owner's table: selectable and filterable through Query Manager, present in metadata and forms, and taking part in history like any other field.

## What you may declare

| Allowed | Not allowed in extension mode |
|---|---|
| `@Column()` of any type | `primary: true` — the owner's primary key is not yours to redefine |
| `@ManyToOne()`, `@ManyToMany()` | `@OneToMany()`, `@ManyToManyBackRef()` — a reverse relation creates a field on the other entity |
| `@Enum()`, `@MultiEnum()`, `@File()`, `@MultiFile()` | `nullable: false` without a default |
| `@Index()` / `@Check()` over your own fields | `@Index()` / `@Check()` covering a field you do not own |

`nullable: false` is the one worth understanding: adding a NOT NULL column to a table that already has rows requires a value for those rows, so a default is mandatory.

> **WARNING**
> On a large table — `Users` in a mature workspace — backfilling that default holds a lock for as long as the migration transaction runs. If the table is big, ship the column as nullable first and fill it separately.

## Name collisions fail the build

A field name on an entity has exactly one owner. If the name you declare belongs to the platform or to another application, the build fails and names the owner:

```txt
[suppa:migrations] cannot declare field "Tasks.plan": it already exists and is
owned by application "Planner".
```

Nothing is applied — no partial artifact. The check is **by name, not only by key**: a collision is reported even when the other owner declared that name with an explicit key. A name stays taken while a soft-deleted row still holds it, which is why removing a field does not immediately free its name for someone else.

**Custom fields are not a collision.** A field a user added through the workspace UI is not an owner for this check. When your declaration catches up with such a field — same entity, same name — you take it over: the field becomes part of your application's schema, keeping its column and its data, and from then on it follows your declaration.

## Removing and restoring your extension columns

They follow exactly the remove / re-add semantics of your own entities: dropping the property soft-deletes the field and renames the column, re-declaring it restores the same row with its data. What you cannot do is remove a field you do not own — it is not in your declaration in the first place, and the owner's fields are never part of your diff.

## Requirements

Extension mode needs a platform new enough to store per-row ownership and a runner that passes your application's id to the SDK. If the platform is older, the build refuses to produce an artifact rather than write rows that could never be attributed afterwards:

```txt
[suppa:migrations] refusing to build system metadata: this is an application
build but applicationId is missing.
```

Update the platform if you see it — there is nothing to change in your code.

## Known limits

- **Global enum names are shared.** `@Enum('SharedStatus', …)` has no owning entity, so two applications using the same global name write to the same enum rows. Prefer an entity-scoped enum (the default, when you pass no name) unless sharing is what you want — which is also SKILL.md rule 18.
- **Uninstalling an application does not remove its extension columns.** They stay on the owner's table until someone removes them by hand.

## Example: two applications on one entity

Application **Planner** owns `Tasks`:

```ts
@Entity({
	name: 'Tasks',
	title: { en: 'Tasks', uk: 'Задачі' },
	representativeFieldName: 'name',
})
export class Tasks extends SystemBaseEntity {
	@Column({ type: FieldTypeEnum.Text, nullable: false })
	name: string;
}
```

Application **Billing** adds two columns of its own to the same entity, and an index over them:

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

@Entity({ name: 'Tasks' })
@Index('idx_tasks_billing_plan', ['plan'])
export class TasksBillingExtension extends SystemBaseEntity {
	@Column({
		type: FieldTypeEnum.Text,
		nullable: true,
		title: { en: 'Billing plan', uk: 'Тарифний план' },
	})
	plan?: string;

	@Column({
		type: RelationTypeEnum.ManyToOne,
		relationEntityName: 'Invoices',
		relationFieldName: 'id',
		nullable: true,
		title: { en: 'Invoice', uk: 'Рахунок' },
	})
	invoice?: { id: number };
}
```

After both applications start, the `Tasks` table has `name`, `plan` and `invoiceId`. Planner's declaration is unchanged and unaware of the two extra columns; Billing owns them and can alter, remove or restore them. Neither application can modify the other's fields, and `Tasks` itself — its title, its representative field — still belongs to Planner.

> **HOUSE RULE — the filename declares the intent (SKILL.md rule 20).**
> Nothing in the code above says which mode it will get: the platform decides by ownership at start. Name the file `<owner-entity>.extension.entity.ts` and the class `<EntityName>Extension`, as the doc's own examples do. That is what lets the validator apply the restrictions in this section BEFORE the build refuses them — W618, E118, E119, E120, E121 — instead of after. Write an index's column list as a literal array while you are here: `@Index('idx', COLUMNS)` compiles and therefore ships, and nothing can then tell whether it reaches into the owner's schema (E122). The same holds for the options objects themselves: `@Entity(OPTS)` or `@Column(OPTS)` hides `primary`, `nullable` and even which entity is being extended, so an extension writes them out literally (E123).
>
> Note the relation form. There is no class to import for an entity another application owns — and the SDK takes **no string target**: `@ManyToOne` is typed `() => Target` and *calls* it at registration (`const entityClass = lazyTarget()`), so `@ManyToOne('Invoices')` fails to compile and would throw if it did. Name the entity on a raw column instead: registration reads `relationEntityName` and never calls a thunk. The property is still typed by shape — `invoice?: { id: number }`. A string target is W620 everywhere, this section included.
