# Migrations and schema change

Routed from `skills/SKILL.md`. The authoritative operation list is
[api-reference.md](api-reference.md).

## There is no migration operation family

NOT-SERVED-FAMILY: `bun.migration.*` `bun.schema.*`

Bun Protocol serves no `bun.migration.*` and no `bun.schema.*` operation. If you
are looking for one, stop looking — it does not exist and must not be invented.
Migration is tooling (`bunprotocol/migrations/`, and Alembic for the backend), and
the closest operation-layer surface is the deploy family:

- `bun.deploy.plan`, `bun.deploy.apply`, `bun.deploy.status`,
  `bun.deploy.rollback`, `bun.deploy.describe`

These require the `deploy:read` / `deploy:write` permission and do **not** require
tenant context.

## The trap that has cost this repository five real defects

**The backend is Alembic-managed.** `SQLModel.metadata.create_all` never alters an
existing table, so changing a model does not reach the database. A model-only
change looks correct in tests that build a fresh schema and silently drifts in any
environment that already has the table.

If you add or change a model, **write a migration**. A drift check exists
specifically to catch this omission; run it rather than assuming.

A second, related trap: row-level security policies are DDL, not ORM metadata.
Creating tenant-scoped tables through the ORM produces
`relforcerowsecurity = FALSE` unless RLS is applied explicitly afterwards. A table
created that way is unprotected while every ORM-level test still passes.

## What is proven

**PostgreSQL to PostgreSQL** migration: proven live with zero data loss, and
rollback restores.

**Cross-engine migration is NOT proven.** Do not describe the protocol as
engine-portable.

## Reproducing

Ordinary lane, from `bunprotocol/`:

```
python -m pytest migrations -p no:logfire -p no:deepeval
```

The live PostgreSQL-to-PostgreSQL lane requires Docker infrastructure
(`bash postgres/up.sh`) and runs as `npm run test:migrations-pg`. A skipped live
lane is not a pass — if the dependency is missing, the correct outcome is a loud
BLOCKED.
