Risk model
Every op kind has a published risk level, a score, and a list of reasons. The numbers drive the CI gates — same numbers in dev terminal, PR comment, and pipeline output.
Six levels
| Level | Name | Meaning |
|---|---|---|
| **0** | safe | Adds something nullable. Can be reversed cleanly. e.g. addColumn (nullable, no default). |
| **1** | low | Adds something with a default backfill. addColumn (default), addIndex. |
| **2** | medium | Locks a table briefly. alterColumnType (compatible cast), addUnique on a small table. |
| **3** | high | Long lock or rewrite required. addUnique on a large table, addNotNull on a populated column. |
| **4** | destructive | Irreversible without recovery. dropColumn, dropTable, dropIndex on hot path. |
| **5** | blocking | Requires manual intervention. renameTable of a heavily-referenced table, schema-rename of an extension. |
The twenty reason codes
| Code | Means |
|---|---|
destructive | The op removes data without an artifact backup. |
non-reversible | There's no automatic down for this op. |
lock-table-write | Postgres takes an ACCESS EXCLUSIVE lock for the duration of the op. |
lock-row | Row locks held during a backfill. |
rewrite-table | Postgres rewrites the whole table on disk. |
rebuild-index | An existing index is rebuilt (CONCURRENTLY when possible). |
backfill-required | The op leaves NULLs that subsequent reads will fail on. |
type-cast-data-loss | The cast can lose precision (text → varchar(50)). |
type-cast-incompatible | Cast may fail on some rows (text → int). |
fk-validate | Adding a FK validates the entire child table. |
unique-validate | Adding a unique constraint validates every row. |
null-validate | SET NOT NULL validates every row. |
extension-required | Op assumes a Postgres extension that isn't installed. |
extension-version | Op requires a newer extension version than is installed. |
policy-rls | Touches RLS — read carefully before applying. |
generated-column | GENERATED column with an immutable expression. |
large-table | Op runs against a table above the size threshold. |
hot-path-index | Index used by the live query plan. |
replication-impact | Op breaks logical replication slots / publications. |
prefer-expand-contract | Suggests using [expand-contract](/docs/migrate/expand-contract) instead. |
The three CI gates
SHELL
8 lines# Hard cap — fail if any op exceeds the level.zanith migrate plan --max-risk 3 # Block specific reason codes — fail if any op carries one of these.zanith migrate plan --block destructive,non-reversible # Whitelist failure — fail unless every blocking finding is in the allowlist.zanith migrate plan --fail-on lock-table-write --allow-with-commentWire all three into your PR pipeline. --max-risk stops the obvious foot-gun; --block codifies team policy; --fail-on is the escape hatch when one specific reason needs sign-off rather than a hard block.
Reading the output
TXT
10 lines$ zanith migrate plan─── pending migrations ──────────────────────────────────────────────20260504_120000_add_orders_sku_index · addIndex level 1 (low) — rebuild-index, hot-path-index20260504_130000_drop_orders_archive · dropTable level 4 (destructive) — destructive, non-reversible → suggested: archiveTable, then dropTable later─── summary ────────────────────────────────────────────────────────2 ops · max risk 4 · 1 destructiveexit non-zero (--max-risk 3)Programmatic risk
TS
7 linesimport { classifyRisk, summarizePlan } from 'zanith'; const single = classifyRisk(op);console.log(single.level, single.reasons); const summary = summarizePlan(plan.ops);console.log(summary.maxRisk, summary.reasonCounts);Both functions are pure. Use them in your own dashboards or tests when you want the same scoring outside the CLI.