zanith

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

LevelNameMeaning
**0**safeAdds something nullable. Can be reversed cleanly. e.g. addColumn (nullable, no default).
**1**lowAdds something with a default backfill. addColumn (default), addIndex.
**2**mediumLocks a table briefly. alterColumnType (compatible cast), addUnique on a small table.
**3**highLong lock or rewrite required. addUnique on a large table, addNotNull on a populated column.
**4**destructiveIrreversible without recovery. dropColumn, dropTable, dropIndex on hot path.
**5**blockingRequires manual intervention. renameTable of a heavily-referenced table, schema-rename of an extension.

The twenty reason codes

CodeMeans
destructiveThe op removes data without an artifact backup.
non-reversibleThere's no automatic down for this op.
lock-table-writePostgres takes an ACCESS EXCLUSIVE lock for the duration of the op.
lock-rowRow locks held during a backfill.
rewrite-tablePostgres rewrites the whole table on disk.
rebuild-indexAn existing index is rebuilt (CONCURRENTLY when possible).
backfill-requiredThe op leaves NULLs that subsequent reads will fail on.
type-cast-data-lossThe cast can lose precision (textvarchar(50)).
type-cast-incompatibleCast may fail on some rows (textint).
fk-validateAdding a FK validates the entire child table.
unique-validateAdding a unique constraint validates every row.
null-validateSET NOT NULL validates every row.
extension-requiredOp assumes a Postgres extension that isn't installed.
extension-versionOp requires a newer extension version than is installed.
policy-rlsTouches RLS — read carefully before applying.
generated-columnGENERATED column with an immutable expression.
large-tableOp runs against a table above the size threshold.
hot-path-indexIndex used by the live query plan.
replication-impactOp breaks logical replication slots / publications.
prefer-expand-contractSuggests using [expand-contract](/docs/migrate/expand-contract) instead.

The three CI gates

SHELL
# 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-comment

Wire 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
$ zanith migrate plan
pending migrations
20260504_120000_add_orders_sku_index
· addIndex level 1 (low) rebuild-index, hot-path-index
20260504_130000_drop_orders_archive
· dropTable level 4 (destructive) destructive, non-reversible
suggested: archiveTable, then dropTable later
summary
2 ops · max risk 4 · 1 destructive
exit non-zero (--max-risk 3)

Programmatic risk

TS
import { 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.