BRACE crash-test lab for postgres migrations
docs
github open the lab →

Manual · Brace · the crash-test lab for Postgres migrations

How Brace works.

Brace runs the migration you are nervous about against a full-scale copy of a database, under live write traffic, and counts how many writes it kills. This is the manual: the science under it, the three ways to run it, hostile mode, self-hosting against your own schema, the API — and exactly where the rehearsal stops matching reality.

What Brace is

A crash-test lab for database migrations.

In one line: Brace clones a database at production scale, fills it with live write traffic, then slams your migration into it — so the freeze happens to a crash-test dummy instead of to your users.

A schema change that looks harmless on an empty staging table can lock a live table for tens of seconds. During that lock, writes pile up; the ones that wait too long fail outright. Brace exists to show you that — as a measured number of dead writes on a throwaway copy — before the same statement touches production.

The Brace lab during a naive CREATE INDEX: the live write-rate chart craters from several thousand writes per second to zero the instant the lock lands, while the casualty counter climbs.
A real run on the live lab — a naive CREATE INDEX: writes crater to zero on a throwaway twin while the casualty count climbs.

The whole idea is the same end state, two fates. The same index — orders (customer_id) — can be built two ways. Both leave an identical index behind, yet one is safe to run against a live table and the other is an outage:

what most people ship
-- takes a SHARE lock and holds it for the whole build — every write queues behind it
CREATE INDEX idx_orders_customer_id ON orders (customer_id);

On Brace's 10-million-row twin, under live checkout / login / cart traffic, that holds its SHARE lock for the entire build: 28.0 seconds of frozen writes and 432 writes killed at the lock_timeout — real 55P03 errors, not a simulation of them. (Magnitude varies with load, roughly 7.5–28s and 144–432 killed; the verdict is the finding, the decimal is not.)

the rewrite Brace suggests
-- SHARE UPDATE EXCLUSIVE — builds the same index without ever blocking writers
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);

Same index, same twin, same traffic: 0.00 seconds frozen, 0 writes dead. Both are single statements the lab runs and measures end to end, so the contrast is counted, not asserted. Brace does not read your SQL and guess; it runs your SQL and counts the bodies.

The disposable-twin lifecycle A permanent template is cloned into a throwaway twin; the migration runs and is measured on the twin; the twin is then dropped. The real template is never touched. brace_template permanent · 10M rows clone disposable twin TEMPLATE copy run + measure under live traffic DROP discarded every run gets a fresh twin
Every crash happens on a physical copy that is dropped when the run ends. Nothing real is ever at risk.
a statement, or a sequence

A run is one statement or an ordered sequence. A real migration is usually a script — add a column, then index it; add a NOT VALID constraint, then VALIDATE it. Pass statements: [ … ] and Brace runs them in order on one twin, so a later step sees the schema the earlier steps produced (a CREATE INDEX on a column the sequence just added would ERROR as two separate runs, because each single run gets a fresh clone). It reports a per-statement verdict plus the worst-of overall — so a deploy gate blocks if any step would freeze writes, and you see exactly which step is the killer. Up to 10 steps — in the web lab (the "run a multi-step migration" panel), or via the MCP and API.

Deploy your own

Stand up your own Brace, or use the public lab.

Brace needs a real Postgres superuser — to create and drop twin databases every run — and native CREATE DATABASE … TEMPLATE cloning. Both are rare on managed Postgres and native to Zerops. One import.yml stands up all four services.

deploy — four services: db, api, web, mcp
zcli project project-import ./import.yml   # db · api · web · mcp
zcli push --serviceId <api-id>             # setup: api — builds cmd/brace
zcli push --serviceId <web-id> --setup web --workingDir ./web
zcli push --serviceId <mcp-id> --setup mcp # the hosted MCP endpoint (optional)

That is it: db (postgresql@16, dedicated 2–3 vCPU, NON_HA), api (go@1), web (static), and mcp (python@3.12, the hosted MCP endpoint), wired by zerops.yml and import.yml. The api seeds its own 10M-row template on first boot; /healthz answers immediately, and /api/* reports {"error":"starting"} until the template is warm. The mcp service is optional — the lab, CLI and local MCP work without it.

Prefer to try it before you deploy? The public lab needs no account and no login — paste a migration and watch it crash. Everything on this page is measured there.

The science

Why a lock freezes writes — and how Brace measures it.

explain it plainly

Picture a busy shop with one checkout lane open. To restock a shelf, a clerk closes that lane and stands in front of it. Every shopper with a full cart just waits — they can't pay. Wait long enough and some of them give up, abandon the cart, and walk out.

A locking migration does exactly that to a database table: while it changes the table, every write to that table queues behind it. Writes that wait past their timeout don't merely slow down — they fail and roll back. Those abandoned carts are your users' failed checkouts, logins and orders. Brace's job is to count them before it's your real shop.

How a rehearsal runs

One run is a fixed pipeline. Every stage happens on the disposable twin, and the whole thing takes about ten seconds:

How one rehearsal runs, step by step Paste the migration; clone a throwaway twin from the 10M-row template; start live labeled write traffic with 24 workers under a 1.5 second lock timeout; run the migration under that load; measure the freeze two independent ways — write-rate collapse and pg_locks sampled every 250 milliseconds; produce a verdict with casualties counted by label; then drop the twin. 01 Paste the migration 02 Clone a throwaway twin from brace_template · 10M rows 03 Start live write traffic 24 workers · lock_timeout 1.5s · labeled 04 Run the migration under load 05 Measure two independent ways write-rate collapse + pg_locks @250ms 06 Verdict + casualties by label SAFE / UNSAFE · 55P03 count Twin dropped — nothing real touched
Seven stages, all on a throwaway copy. The verdict is a counted fact, not a static guess.

Not every lock is the same lock

Postgres has a ladder of lock strengths. What a statement freezes depends on which rung it grabs:

  • SHARE

    Taken by a plain CREATE INDEX. Blocks writes (INSERT / UPDATE / DELETE). Reads still work.

    conflicts with ROW EXCLUSIVE

  • ACCESS EXCLUSIVE

    Taken by an ALTER TABLE that rewrites the table. Blocks everything — reads included.

    the strongest lock

  • SHARE UPDATE EXCLUSIVE

    Taken by CREATE INDEX CONCURRENTLY, VACUUM. Blocks nothing. Writers and readers keep going.

    the safe path

A blocked write doesn't wait forever. Every write in the load generator runs under a lock_timeout of 1.5 seconds; a write that waits longer is cancelled by Postgres with SQLSTATE 55P03 (lock_not_available) and rolled back. That is a casualty — a write a real user issued that actually died.

Why writes die but reads don't

A plain CREATE INDEX takes SHARE, which conflicts with the ROW EXCLUSIVE lock every INSERT, UPDATE and DELETE needs — so every write queues. But SELECT only takes ACCESS SHARE, which does not conflict, so reads sail through. That is why this outage looks like "the site is up but nothing saves."

Which traffic the SHARE lock conflicts with A plain CREATE INDEX holds a SHARE lock. Writers take ROW EXCLUSIVE, which conflicts, so writes queue and die at the lock timeout with error 55P03. Readers take ACCESS SHARE, which does not conflict, so reads keep flowing. plain CREATE INDEX holds a SHARE lock Writers — ROW EXCLUSIVE INSERT · UPDATE · DELETE CONFLICTS → queue die at lock_timeout · 55P03 Readers — ACCESS SHARE SELECT NO CONFLICT → pass reads keep flowing
The signature of a locking migration: the site is up, but nothing saves.

Measured two independent ways

Brace watches the freeze with two separate instruments and expects them to agree:

  • Write-rate collapse — the load generator's throughput falling toward zero writes per second the moment the lock lands.
  • The catalog itselfpg_stat_activity and pg_locks, sampled directly every 250 ms during the run, showing the blocking lock held and the writers queued behind it.

A casualty is counted only for a genuine 55P03 lock timeout — never inferred from a slow response, a cancellation (57014), or a deadlock (40P01). The report then translates the raw count into user terms: not "432 writes rolled back" but 432 denied checkouts, logins and cart-adds, broken out by label.

what the labels are

Those three labels — checkout, login, add_to_cart — are simulated. In the stock lab all three run the identical synthetic INSERT INTO orders; the names are cosmetic tags applied by weight (5 add-to-cart : 3 checkout : 2 login), not three distinct operations. So "432 denied checkouts and cart-adds" means "432 synthetic writes, split across three labels" — a realistic shape of failure, not a replay of real user actions. To label your own real write mix, self-host with BRACE_LOAD_LABELS (see Bring your own data).

The Brace report card for a naive CREATE INDEX: an UNSAFE verdict, 28.0 seconds frozen, 432 writes killed broken out by label, the freeze measured two independent ways at 28.0s and 28.0s, a plain-English explanation of the SHARE lock, and the freeze projected onto a 100-million-row table.
The report card from that exact run — UNSAFE, 28.0s frozen, 432 writes killed by label, the freeze measured two independent ways (28.0s / 28.0s), the lock explained in plain English, and projected onto a 100M-row table.

What that looks like across common migrations

One run, 10M-row twin, several thousand writes/sec baseline, on the hardware Brace was measured on (dedicated 2–3 vCPU, PostgreSQL 16):

MigrationVerdictFreezeWrites killed
ADD COLUMN … NOT NULL DEFAULT random()unsafe31.0s480
ADD COLUMN z serial (looks harmless — isn't)unsafe26.2s408
ALTER COLUMN … TYPE …unsafe20.5s312
CREATE INDEX (naive)unsafe7.5–28.0s144–432
REINDEX INDEXunsafe11.0s168
CREATE INDEX CONCURRENTLYsafe0.00s0
VACUUM ANALYZEsafe0s0

Notice ADD COLUMN z serial: it looks like the harmless case and passes most linters, yet it holds the same table-rewrite lock as the obviously-dangerous DEFAULT random() version. That is the gap a rehearsal closes and a static rule can't.

Hostile mode

Measuring the freeze a fast statement would cause.

The problem it solves: some dangerous statements are dangerous only under contention. On an idle twin, a statement like ALTER TABLE … ADD COLUMN (with no default) acquires its ACCESS EXCLUSIVE lock instantly, does its work in milliseconds, and reports zero casualties. In production, that same statement has to wait for the lock — behind whatever transaction is already touching the table — and while it waits, every other write waits behind it.

Hostile mode holds a transaction open on the twin so the migration's lock acquisition has to queue, exactly as it would in production. The freeze that would otherwise be assumed is now measured:

Hostile mode: the freeze is the wait to acquire the lock A contender transaction holds the lock; the migration queues behind it, and every write queues behind the migration. PostgreSQL grants locks in arrival order, so until the contender's transaction ends, everything behind it is frozen. lock queue · granted in arrival order → frozen — every write waits for the lock contender · open txn HOLDS the lock your migration fast DDL · WAITS every write behind it queues, then times out
Off an idle twin a fast DDL grabs its lock instantly and looks safe. Hostile mode holds a real transaction so the DDL must queue — and every write queues behind it. The freeze is the wait to acquire the lock, not the work.
Hostile modeVerdict for ADD COLUMN foo intWhy
offsafe · 0.0s, qualifier acquisition_not_simulatedInstant in isolation — the wait was never tested, so Brace refuses to call it an unqualified green light.
onunsafe · ~2s frozen, measuredIt queued behind the held transaction, and every write queued behind it.

This is the real-world lock-acquisition cascade — the outage pattern nobody else reproduces — turned into a measured number instead of a footnote. Set it per run (hostile: true) or globally (BRACE_HOSTILE).

By default Brace holds that transaction for 2 seconds per cycle and reports the figure back as contention.hold_seconds. The measured freeze is therefore relative to that hold: it is what this migration does when it has to queue behind a 2s transaction — shorter if nothing on your table ever stays open that long, longer if something does. It is a measurement of the migration behind a stated hold, not a property of the migration alone, and the report says so.

no false greens

Hostile mode can't invent a freeze either. If the migration's lock never actually conflicts with the held transaction — say ALTER INDEX … RENAME, which takes only SHARE UPDATE EXCLUSIVE and blocks nothing — the run reports contention_not_engaged rather than claiming a freeze it didn't cause. Turn it on for fast DDL whose real risk is the wait for the lock; leave it off for statements that already do heavy work under the lock.

Scale projection

The twin is a sample of your database, not your database.

The key fact: whether a statement takes a blocking lock does not depend on table size — but how long it holds that lock does, because the work under the lock (a rewrite, a full index build) scales with row count. So a 10M-row twin can measure a freeze too short to call unsafe, while the same statement against a 500M-row production table would be an outage.

Brace's answer is a projection, not a bigger twin. Tell it your real row count — BRACE_TARGET_ROWS in the web lab, -target-rows on brace-check, or target_rows over MCP — and it projects the measured freeze onto that count. A CREATE INDEX measured at 7.5s on 10M rows projects to 375s at 500M rows — still unsafe, and worse than the raw measurement let on.

The verdict then follows effective_verdict: measured or projected, whichever is worse. Two rules keep it honest:

  • A projection can escalate a verdict — safe on the twin can become unsafe for your real table. That is what stops a small-twin measurement from reading as a false green light.
  • A projection can never soften one. A measured unsafe stays unsafe no matter what row count you supply.
assumption

The projection is a linear-in-rows floor, not a second measurement: projected = measured × target ÷ twin. Real index-build and table-rewrite cost is super-linear (≈ O(n log n) or worse), so the projected freeze is a lower bound — the true freeze is at least this and usually longer, never shorter. Past ~20× the twin the number is an order of magnitude, not a figure: the projection then carries qualifier: "modeled_beyond_measurement". It always reports target_rows_sourcerequest (you passed it), deployment (the lab's BRACE_TARGET_ROWS), or brace_default_assumption (nobody said, so Brace assumed a size, also flagged target_rows_assumed: true, and says so loudly). It has not been validated across arbitrary index types, fill factors, or hardware, and it will be wrong in ways a real crash test at your actual scale would not be.

Using it

Three ways to run a rehearsal.

The same engine answers the same question — "will this migration hurt?" — whether a human, a CI pipeline, or an AI agent is asking.

The web lab

Paste a migration, or pick a preset, and watch it crash. The write-rate chart collapses live as the lock lands, and a report card lands the moment the twin stops moving: freeze duration, casualties by label, the exact lock mode taken, and — on an unsafe verdict — a safe rewrite that has already been rehearsed, not just recommended. No account, no login.

Using it · CI

The brace-check deploy-gate.

Rehearse a migration inside your pipeline and let the exit code block the merge. brace-check exits non-zero when a migration is unsafe, so a dangerous statement can't reach the default branch. The exit code is a strict contract:

exit-code contract
brace-check -f migration.sql
# 0  → SAFE     — merge it
# 1  → UNSAFE   — froze writes > 1s OR real writes died; block the deploy
# 2  → rejected / errored / inconclusive — no clean verdict

# project the measured freeze onto your real table size:
brace-check -target-rows 500000000 -f migration.sql

The exit code follows effective_verdict — measured or projected, whichever is worse — so a statement that was borderline on the 10M-row twin fails the gate once you tell it the table is 50× bigger. Run a linter on every commit; run this before the migration touches production.

Using it · agents

The MCP verb an agent calls.

A local AI assistant — Claude Code, Cursor, or any MCP client — can call Brace directly. An agent that writes a migration in seconds should be able to ask "will this hurt?" in the same breath, against a real production-scale rehearsal rather than a linter's guess. Run mcp/server.py (FastMCP, over stdio) and it exposes three tools: check_migration to rehearse a statement, get_result to fetch a rehearsal too slow to finish inline, and lab_status to read the warm-twin pool.

the verb an agent calls
check_migration(sql, target_rows=0, hostile=false, statements=[],
                deadline_ms=0, schema_ddl="", row_count=0, wait_seconds=45)
# returns JSON the agent can branch on:
#   effective_verdict  → gate on THIS: worst-of measured + projected
#   verdict            → what was measured on the twin alone
#   blocked_seconds    → how long writes froze
#   casualties         → real writes killed, broken out by label
#   statements         → per-step results, when you passed a sequence
#   suggestion         → the safe rewrite, on an UNSAFE verdict
#   qualifier / note   → what this run did NOT establish, in words
#   contention         → whether hostile mode's hold actually engaged
# — so the agent can refuse to ship a locking migration.

It returns the same verdict, casualties and suggested rewrite the web lab shows — as JSON — so a coding agent can gate its own output the way CI gates a human's. Always branch on effective_verdict, never on verdict: verdict is only what the twin measured, while effective_verdict also folds in the projection onto your real target_rows, so a change can read SAFE on the twin and UNSAFE at your scale.

A whole migration script, not one statement

Real migrations are files, not single statements. Pass statements — an ordered list — and Brace runs them in order on one twin, so a later step sees the schema the earlier steps produced. Every step passes the same allow-list a single run does (a sequence can't smuggle a non-allow-listed verb), it is capped at 10 steps, and the top-level verdict/effective_verdict are the worst-of — so gating on effective_verdict blocks the deploy if any step would freeze writes.

verify a multi-step rewrite as the script it actually is
check_migration(statements=[
  "ALTER TABLE orders ADD COLUMN promo int",
  "CREATE INDEX idx_orders_promo ON orders (promo)"   # depends on step 1
])
# → verdict: "UNSAFE"  (worst-of; the CREATE INDEX froze writes)
# → statements: [
#     { index:0, verdict:"SAFE",   blocked_seconds:0,    … },
#     { index:1, verdict:"UNSAFE", blocked_seconds:27.8, casualties:{…} } ]
# each step measured in its own window — you see WHICH step is the killer.
A sequence: steps in order on one twin, each measured in its own window Two statements run in order on the same twin under continuous load. Step one adds a column and is safe; step two indexes that new column and freezes writes. The overall verdict is the worst of the two. one twin · load runs the whole time 1 · ADD COLUMN SAFE · 0.0s then 2 · CREATE INDEX on the new column UNSAFE · 24.6s frozen · 384 killed overall = worst-of = UNSAFE
Step 2 indexes a column step 1 just added — on the same twin, so it doesn't ERROR the way two separate runs would. Each step is measured on its own; the overall verdict is the worst step.

A step that is SAFE only because nothing was holding the table — a metadata-only ACCESS EXCLUSIVE shape that got its lock instantly — still carries qualifier: "acquisition_not_simulated" in its per-statement result, exactly as a single run would. hostile can't combine with statements (a sequence can't hold one contended lock across the whole script); to measure a step's acquisition wait, run that one statement on its own with hostile=true.

Match your application's real deadline

A "casualty" is a write that waited longer than a deadline and was killed — so the count depends on which deadline. Pass deadline_ms to set it to your application's real per-request lock timeout (the wait after which your own client gives up), and casualties.deadline_ms comes back reflecting your environment rather than the lab default. The freeze duration is the same regardless; only who survives it changes.

Slow migrations: PENDING, then get_result

A real migration can outlive the client's tool timeout. CREATE INDEX CONCURRENTLY, table rewrites and ALTER COLUMN … TYPE routinely run 40–90 seconds, longer than the ~60 s budget most MCP clients allow. So check_migration never holds the call open past its wait_seconds (default 45): when the rehearsal is still running at the deadline it returns a recoverable status rather than losing the run —

the async flow — a slow rehearsal is never lost
result = check_migration(sql)
# slow run still going at the deadline:
#   { "verdict": "PENDING", "run_id": "…", "next": "call get_result(run_id) …" }

# the run is NOT lost — poll for the finished verdict with its run_id:
result = get_result(run_id)
# returns the SAME rich shape check_migration returns —
# effective_verdict, blocked_seconds, casualties, suggestion, … —
# or PENDING again if it is still in flight (poll once more).

This is the answer to "how do I test a slow migration?": on PENDING, call get_result(run_id) a few seconds later. Runs are serialised, so the run you just submitted is the one the lab is holding as its most-recent result — poll promptly, because a newer run supersedes the rich result it can hand back.

The verdict enum a strict gate can see

Every value check_migration and get_result can put in the top-level verdict / effective_verdict / status fields, so an agent author knows exactly what to branch on and is never surprised by an undocumented one. Nothing here is a raw HTTP error — the server maps each one to a named, typed status.

StatusMeaning · what to do
SAFEWrites kept flowing; safe at the tested size. Still read effective_verdict — the projection can escalate it.
UNSAFEWrites froze >1 s and/or real writes died. Block the deploy; the safe rewrite is in suggestion.
INCONCLUSIVEAborted before it could finish (e.g. never acquired its lock in budget). Not a pass.
ERROREDThe statement could not execute at all (bad SQL, permission denied, a run that failed before measurement). Never safe.
SAFE · statically_safeThe already_safe fast path: a catalog-only shape (ADD COLUMN with a constant default, ANALYZE, COMMENT) proven safe by shape without a run. If it still takes ACCESS EXCLUSIVE it carries qualifier: "acquisition_not_simulated" — its acquisition wait was not simulated; re-run with hostile=true.
REJECTEDThe lab will not run this input (not a migration, more than one statement crammed into a single sql field — use statements: [ … ] for a sequence — too long, or a target relation that does not exist). See reason / suggestion. Treat as not-safe.
RATE_LIMITEDToo many runs — a graceful 429, never a raw HTTP error. Retry after retry_after_seconds.
BUSYAnother crash test is in progress; retry shortly.
WARMINGNo warm twin yet; retry after the ETA in reason.
PENDINGThe run did not finish within wait_seconds but was not lost. Carries run_id; call get_result(run_id).

The four measured/derived outcomes (SAFE, UNSAFE, INCONCLUSIVE, ERRORED) can appear in both effective_verdict and verdict; the submission and transport statuses (REJECTED, RATE_LIMITED, BUSY, WARMING, PENDING) are never a measurement and appear in verdict only. A gate that treats everything except a clean SAFE as "do not ship" is correct.

A statement against a table the lab doesn't have

The public lab carries one synthetic table — orders. A statement whose target relation isn't there is not blessed SAFE: a metadata-only shape that would otherwise fast-path to a clean pass comes back REJECTED instead, naming the missing relation and listing the tables the lab actually has —

REJECTED — nothing to rehearse, so nothing to bless
check_migration("ALTER TABLE users ADD COLUMN verified boolean")
{ "verdict": "REJECTED",
  "reason": "Relation \"users\" does not exist in the lab … The lab's tables are: orders.",
  "suggestion": "Target one of the lab's tables: orders." }

That is exactly why rehearsing your schema means self-hosting: point BRACE_API at your own instance, restore your dump as the template, and the same tools now crash-test against your tables. See Bring your own data.

Connect it to your agent

The fastest path needs nothing installed — Brace runs a hosted MCP endpoint on Zerops. Any MCP-capable agent (Claude Code, Cursor, Windsurf, Cline, Codex) connects by URL:

remote — connect by URL, no clone
claude mcp add --transport http brace https://mcp-2a61-8080.prg1.zerops.app/mcp

Agents that speak only stdio (e.g. some Codex/Cursor setups) reach the same hosted endpoint through the mcp-remote bridge — for Codex, in ~/.codex/config.toml:

stdio agents — bridge to the hosted endpoint
# Codex — ~/.codex/config.toml
[mcp_servers.brace]
command = "npx"
args = ["-y", "mcp-remote", "https://mcp-2a61-8080.prg1.zerops.app/mcp"]

Or run it locally against your own database — clone the repo and point it at your Postgres (this is how you rehearse your data; see Bring your own data):

local — stdio, your own lab
python3 -m venv mcp/.venv && mcp/.venv/bin/pip install -r mcp/requirements.txt
claude mcp add brace -- "$PWD/mcp/.venv/bin/python" "$PWD/mcp/server.py"
# BRACE_API defaults to the public lab; set it to your own instance to use your data

Every transport exposes the same three tools — check_migration, get_result and lab_status — so an agent gets the async slow-migration recovery path above whether it connects by URL, through the mcp-remote bridge, or over local stdio.

Then just ask your agent: "use brace to check CREATE INDEX idx_orders_email ON orders (email) before I ship it — with hostile mode on." It runs a real rehearsal and refuses to ship an outage.

Using it · your table shape

Rehearse against your own table — no self-host.

The public lab carries one table, orders. Paste your own CREATE TABLE and Brace builds a disposable, isolated database from it, seeds a bounded sample of type-aware synthetic rows, runs your migration against that shape, and drops the whole database when the run ends. This is distinct from bringing your own data: that restores a real dump and needs self-hosting; this rehearses your shape on the shared public lab.

paste a shape, rehearse against it
check_migration(
  schema_ddl="CREATE TABLE invoices (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
                account_id uuid NOT NULL, amount_cents numeric(12,2) NOT NULL,
                created_at timestamptz NOT NULL DEFAULT now())",
  row_count=100000000,                       # your real size → drives the projection
  sql="CREATE INDEX ON invoices (account_id)")
# over HTTP: POST /api/run { schema_ddl, row_count?, sql }

What Brace does and doesn't do with your shape

  • One plain CREATE TABLE. Exactly one statement — CREATE TABLE or CREATE TABLE IF NOT EXISTS. Temporary, unlogged and CREATE TABLE … AS forms are refused, and a second smuggled statement is rejected before a database is spent. Multiple related tables are not accepted here.
  • Type-aware synthetic rows. Brace reads the created table back from the catalog and fills the columns it must — NOT NULL, no default, not identity/generated — from a fixed whitelist (integers, numeric, text/varchar, uuid, timestamp/date/time, json/jsonb, inet, bytea, boolean, text[]). A required column of a type outside that whitelist is rejected with the column named — never silently filled with NULL or a wrong value.
  • Generic write traffic, not personas. The load against your table is a single label named write, so casualties.by_label here has one key — write — not the stock checkout/login/add_to_cart split. Describing a real per-operation mix still needs self-hosting and BRACE_LOAD_LABELS.
  • The seed is bounded; your size drives the projection. row_count is your approximate real table size. It sets the scale-projection target, but the number of rows actually seeded is clamped to 5,000,000 (default 1,000,000 when omitted) so one paste can't starve the shared pool. The freeze is measured on the seeded sample and then projected onto your row_count — the same measured-or-projected effective_verdict the stock lab uses.
planner realism

The synthetic rows are type-correct but not distributed like your data: selectivity, skew and index choice can differ from production even at the same row count. When planner behaviour is exactly what you're rehearsing, restore a real dump instead — see Bring your own data.

Bring your own data · self-host

Rehearse against your schema, not ours.

The public lab crashes every migration into one shared, synthetic 10M-row orders table — a good demo and a weak rehearsal. The migration you are actually afraid of meets your indexes, your row widths and your traffic. A self-hosted Brace points at a restored production dump instead, with two environment variables and no code changes. This is a first-class capability, not a footnote.

worked example A real SaaS billing schema — not the demo table

Suppose the database you are actually afraid of is a billing system: accounts, invoices, sessions — real production types, not toy integers. A uuid primary key, a jsonb line-items blob, a numeric(12,2) amount, an inet last-login address, a text[] of tags. Roughly 1.2M rows of invoices. You point a self-hosted Brace at a restored dump of it and rehearse the exact index you were about to add in production.

the schema Brace is pointed at
-- your restored production dump, loaded as brace_template
CREATE TABLE invoices (
  id           uuid          PRIMARY KEY DEFAULT gen_random_uuid(),
  account_id   uuid          NOT NULL,
  amount_cents numeric(12,2) NOT NULL,
  line_items   jsonb         NOT NULL DEFAULT '[]',
  tags         text[],
  last_ip      inet,
  created_at   timestamptz   NOT NULL DEFAULT now()
);   -- ~1.2M rows; accounts + sessions alongside

Two variables switch Brace off the synthetic seed and onto your own tables and traffic — nothing else changes:

point it at your data
BRACE_SEED: "off"                 # use the restored template as-is; never seed
BRACE_LOAD_LABELS: >-             # describe your real write mix
  checkout:3:INSERT INTO invoices (account_id, amount_cents)
    VALUES (gen_random_uuid(), $1 % 90000 + $2 * 0);
  touch:2:UPDATE sessions SET last_seen = now() WHERE id = $1 % 50 AND $2 > 0

Now rehearse the naive index you were about to ship — a plain CREATE INDEX on invoices (account_id). On your 1.2M-row twin, under your own checkout and touch traffic, it holds its SHARE lock and freezes writes; then the scale projection warns what the same statement does to the full table:

the report, on your schema
CREATE INDEX idx_invoices_account ON invoices (account_id);

verdict            = UNSAFE
blocked            = 4.2s        (measured, on 1.2M rows)
casualties         = 63         by_label=map[checkout:58 touch:5]
effective_verdict  = UNSAFE     projected 18s at 100M rows
Twin1.2M rows · your dump
Measured freeze4.2s
Writes killed63
Projected · 100M18s outage

Your schema, your row widths, your traffic — the migration you are actually afraid of, measured on a throwaway copy of it. The numbers here are representative of a self-hosted run against a foreign schema, not a claim about the shared public lab.

Stand it up

One import.yml stands up all four services on your own Zerops project — web (static), api (Go engine), mcp (the MCP endpoint), and db (PostgreSQL 16). Brace needs a Postgres it owns: it creates and drops databases on every run, so don't point it at a server anything else depends on.

Restore your dump as the template

Every disposable twin is cloned from a database named brace_template — the name is fixed, because the twin pool, the orphan sweep and the boot check all agree on it.

step 1 — the template
createdb  -U <superuser> brace_template
pg_restore -U <superuser> -d brace_template your-dump.dump

Every warm twin is a full physical copy of the template, so trim it before you restore if you can — roughly 80 seconds of clone time per gigabyte. Only the tables named by your migration and your load statements have to be realistic.

Turn seeding off and describe your traffic

Set BRACE_SEED=off so Brace uses the restored template as-is instead of appending synthetic rows, and describe your write mix with BRACE_LOAD_LABELS: semicolon-separated name:weight:statement triples.

  • name — non-empty, no whitespace, unique. It becomes a key in casualties.by_label and a series on the dashboard.
  • weight — an integer > 0; the relative share of write traffic.
  • statement — one statement that must bind both $1 and $2 and nothing higher. Every write binds exactly two bigints, and Postgres rejects a statement that declares fewer. If your second value is genuinely unused, give it a harmless home: … WHERE id = $1 AND $2 > 0.

At boot, Brace clones a twin and runs each label statement against it exactly once. A typo'd table name fails startup with the label name, the SQLSTATE and the statement — because a config that boots but never lands a write would report zero casualties, and zero casualties would look exactly like good news.

The privilege model

Brace has exactly one security boundary, and it is worth knowing where it sits before you hand it a production schema. Two unprivileged roles, each with a fresh random password generated at every process start:

  • brace_migrator runs the migration under test. It is LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS, owning nothing outside the disposable twin. Postgres itself — not application code — refuses COPY … FROM PROGRAM, server-side file reads and DROP DATABASE for that role.
  • brace_traffic runs your load statements, with the least privilege the writes need and nothing more: SELECT, INSERT, UPDATE on existing public tables, never DELETE, TRUNCATE, CREATE or ownership. A CHECK constraint or column default is evaluated as the session that writes the row, so running traffic unprivileged is what stops a smuggled expression from firing as superuser.
your data

Brace runs inside your own infrastructure and the restored template is never written to — with BRACE_SEED=off, boot only reads its catalog, and every run gets a fresh twin that is dropped when the run ends. Your data never leaves your infrastructure.

API reference

The public HTTP surface.

Everything the web lab shows is available over plain HTTP — the CLI and the MCP verb are thin clients over these same endpoints.

post/api/run

Rehearse one migration — or a whole script. Body: { sql, mode?, target_rows?, hostile?, lock_timeout_ms?, statements?, schema_ddl?, row_count? }. Pass statements: [ … ] (an ordered array, ≤10) instead of sql to run a sequence on one twin; pass schema_ddl (one CREATE TABLE, with an optional row_count) to rehearse against your own table shape — the public lab builds an isolated throwaway table from it and seeds it with synthetic rows, no self-host required (distinct from bringing your own data, which needs self-hosting); lock_timeout_ms sets the per-request deadline a casualty is counted against. A run takes about ten seconds, so the call does not hold the request open: it returns 202 { run_id } on admission, then the completed report card is read back from GET /api/stats as last_run and pushed live over the SSE stream. Everything else is a typed rejection with a reason — no raw or unexplained errors (see the status map below). A statement whose target relation does not exist is rejected, never blessed SAFE; hostile combined with statements is rejected too.

get/api/stats

The current live frame — writes/sec, casualties, the running twin — plus last_run, the most recent completed report card (verdict, effective verdict, freeze, casualties by label, lock modes, any scale projection, and on unsafe a suggested rewrite).

get/api/stream

A Server-Sent Events feed of the same stats frames — this is what drives the live write-rate chart in the web lab.

get/api/status

Engine and template state: whether a run is in progress, the warm-twin pool, and the loaded template's row count and size. Answers {"error":"starting"} until the template is warm.

get/api/history

The recent run ledger, newest first — an array of past report cards (optional ?limit=).

get/api/report/{id}

A single run's full report card by id — the same shape as one row of /api/history.

get/healthz

Liveness probe. Returns ok the moment the listener is up — deliberately dumber than readiness, so a platform can't kill the container mid-seed.

How POST /api/run answers

The MCP verb and the CLI are thin clients over this endpoint, and each status below is the HTTP form of a value in the verdict enum. Every 4xx carries a JSON reason; a rate-limit is a graceful 429 with Retry-After, never a raw error:

HTTPBodyMaps to
202{ run_id } — admitted; read the result from /api/statsSAFE / UNSAFE / …
400{ error:"rejected", kind:"already_safe", reason } — proven safe by shape, no runSAFE · statically_safe
400{ error:"rejected", kind:"rejected", reason, suggestion? } — not a migration, multiple statements in one sql field (use statements[]), too long, or a nonexistent target relationREJECTED
413{ error:"rejected", reason } — body over the size capREJECTED
429{ error:"rate_limited" } + Retry-After headerRATE_LIMITED
409{ error:"run_in_progress" }BUSY
503{ error:"no_warm_twin", eta_seconds } · { error:"starting" }WARMING

On a nonexistent relation the 400 rejected body names the missing relation and lists the lab's tables — the public lab has just orders, so DROP INDEX or an ALTER TABLE against your own users or invoices comes back rejected here. To crash-test against your schema, self-host and point BRACE_API at your instance.

Architecture · Why Zerops

What Brace needs from its infrastructure — and how Zerops provides it.

The hard requirement: Brace needs a Postgres it is superuser on. It creates and drops databases on every single run and clones them with native CREATE DATABASE … TEMPLATE — and most managed Postgres offerings allow neither (no superuser, no arbitrary CREATE DATABASE, no TEMPLATE from a live db). Zerops gives you a real Postgres you own, not a locked-down slice — which is the one thing that makes the whole twin mechanism possible.

The four services

Brace is not one box; it is four Zerops services stood up from a single import.yml, each doing one job:

ServiceRuntimeJob
apiGo (go@1)The engine. Clones the twin, drives the labelled load, runs the migration, watches pg_locks, computes the verdict, streams SSE.
webstaticThe dashboard and this manual — plain HTML/CSS/JS, no build step, served straight off Zerops' static runtime.
mcpPython (python@3.12)The hosted MCP endpoint (FastMCP over streamable HTTP). A thin client that turns an agent's check_migration call into an api request over the private network.
dbPostgreSQL 16The lab's Postgres, on dedicated vCPU. Holds the seeded template and every throwaway twin.

The private network is the security boundary

The services talk over Zerops' private network, and that is not a convenience — it is the security model. The db is never exposed publicly; the superuser DSN lives only inside the project and is never in git. The mcp service reaches the engine at http://api:8080 by service name, so the endpoint a stranger's agent connects to has no path to the database except through the same allow-listed, rate-limited engine a browser hits. The role boundary does the rest: your SQL runs as brace_migrator and load as brace_traffic, both NOSUPERUSER — see the self-host security notes.

Why the database has dedicated vCPU

This is the one place the $65 credit is deliberately spent, and it is load-bearing for the demo. The naive run wants high write load so the freeze is punchy; but CREATE INDEX CONCURRENTLY does two full heap scans, and on a shared core those scans starve the writers — the "safe" line sags under the very load that makes the naive line dramatic. Give db its own 2–3 vCPU and the concurrent build has a core the writers aren't fighting for: the naive run still craters and the safe run stays flat under the same load. The whole naive-vs-safe contrast — the money shot — depends on this.

The twin lifecycle Zerops makes cheap

Because Brace owns the Postgres, the twin mechanism is native and fast: a permanent brace_template (seeded, ALLOW_CONNECTIONS false so a stray connection can never block a clone) is copied with CREATE DATABASE … TEMPLATE in well under a second, kept in a small warm pool so a run starts instantly, and torn down with DROP DATABASE … WITH (FORCE) the moment it finishes. Orphaned twins from a crashed run are swept on startup. Every crash happens on a physical copy that is dropped when the run ends — nothing real is ever at risk.

What building on Zerops actually cost — honestly

Two real edges, because a stack page that only lists wins isn't a stack page:

  • The platform proxy turned the MCP's POST /mcp/ into a 307 → GET /mcp and broke the streamable-HTTP handshake. The fix is an ASGI path-normalising wrapper that serves both /mcp and /mcp/ directly — so the remote endpoint works whether or not the client sends the trailing slash.
  • A service isn't reachable on its public subdomain until subdomain access is explicitly enabled (zcli service enable-subdomain); the private-network name works immediately, the public URL needs that one extra step. Worth knowing before you go looking for a URL that isn't on yet.

Deploys are per-service and scriptable — zcli push --serviceId … --setup <name> — which is what made a fix→deploy→verify loop tight enough to iterate on all weekend. The one trap: the engine and the MCP are separate services, so an engine fix isn't live for agents until both api and mcp are redeployed.

easy here, not trapped here

None of this is proprietary. Brace is standard Go, static assets, Python and PostgreSQL 16 with no vendor APIs, so it runs anywhere those do — a single import.yml stands up all four services, which is exactly what makes bring-your-own-data a two-variable change instead of a fork. Zerops is where Brace is easy, not where it is locked in.

The stack · and why

Every piece is boring on purpose.

Brace measures the truth about a database under load, so the stack's only job is to stay out of the way of that measurement. Nothing here is chosen to be fashionable — each piece is the one that lets Brace watch a real lock without a layer of abstraction lying about it.

One engine, three interfaces, its own Postgres The web lab, the brace-check CLI and a coding agent all reach one Go engine over HTTP — the agent through the mcp service — and the engine owns a PostgreSQL 16 it clones disposable twins from. web lab brace-check CI / deploy gate coding agent mcp python private network api · Go engine assess · load monitor · verdict db · PG16 owned · superuser template twin ⇒ dropped
One measurement engine, reached three ways — the web lab and CLI hit it directly, an agent through the mcp service — all over the private network. It owns the Postgres it clones twins from.

Go for the engine — because one run is a concurrency problem

A single rehearsal runs three things at once against one twin: dozens of labelled write workers, a lock monitor sampling pg_locks every 250 ms, and the migration itself. That is a goroutine problem — cheap concurrency, precise cancellation, one static binary with no runtime to install. The engine talks to Postgres through pgx rather than the lowest-common-denominator driver, because it needs the real protocol: to read pg_stat_activity, to set lock_timeout per connection, and to run DDL in autocommit — which CREATE INDEX CONCURRENTLY requires.

Raw SQL, no ORM — because the SQL is the subject

Brace's entire job is to run your exact statement and watch what lock it takes. An ORM exists to hide SQL and locking behind objects — the precise thing being measured. So there is no ORM and no query builder: the migration is your string, the load is three hand-written statements, and the lock readings come straight from the catalog. The role a statement runs as is the security boundary, not a query layer.

A Postgres it owns, not a managed slice — because the feature set demands it

The twin mechanism needs three things managed Postgres almost always forbids: superuser (to create and drop databases every run), native CREATE DATABASE … TEMPLATE cloning, and direct reads of pg_locks. That is why Brace runs its own PostgreSQL 16 instead of pointing at a hosted database — and why Zerops, which hands you a Postgres you actually own, is the natural home.

FastMCP + Python for the agent endpoint — kept deliberately thin

The MCP server is FastMCP on Python, because the MCP client ecosystem is Python-first and FastMCP is its reference implementation. It holds no measurement logic: it shapes an agent's check_migration call into an HTTP request to the Go engine, maps every failure to a typed status, and gets out of the way. All the truth lives in one place — the engine — so the CLI, the web lab and the MCP can never disagree about a verdict.

A static frontend, no framework — because it is one stream

The dashboard is a single Server-Sent Events stream driving DOM updates. A front-end framework would add a build step and a bundle for a page that is, structurally, one EventSource and a chart. So it is vanilla HTML/CSS/JS with no build — and progressively enhanced: the docs and lab stay readable and navigable with JavaScript switched off entirely.

the throughline

One measurement engine in Go, spoken to three ways — web, CLI, MCP — against a Postgres Brace owns. Every choice removes a layer that could stand between you and what the database actually did.

Honesty · the fixes

What we got wrong — and fixed.

A tool that tells you a migration is safe has to be right, so the ways Brace was once wrong are worth naming. Each of these was a real bug caught before submission; each is fixed; each changed how the engine works today. They are here because a measurement tool earns trust by showing its scars, not by hiding them.

The load ran as superuser

What broke: the write traffic once ran as the database superuser. A migration is caller-supplied SQL, and a CHECK constraint or column DEFAULT is evaluated as the session that writes the row — so a smuggled expression could have called pg_read_file() as superuser. The fix: two least-privilege roles — brace_migrator and brace_traffic, both NOSUPERUSER NOCREATEDB — so the role, not a regex, is the boundary. The allow-list is now only UX and blast-radius hygiene; the privilege floor is what actually contains hostile SQL.

A migration could read SAFE when it wasn't

What broke: several shapes could earn a false green light — a DEFAULT using a volatile function that silently rewrites the table, a statement against a table the lab doesn't have (fast-pathed to SAFE), an O(rows) change that finished fast on a small twin. The fix: the monitor's observed lock mode is now the arbiter of whether a freeze is even possible; a nonexistent relation is REJECTED, never blessed; and the scale projection can escalate a twin-SAFE result to UNSAFE at your real row count. A SAFE now has to survive all three.

The dangerous half of a fast statement was invisible

What broke: a metadata-only ALTER TABLE … ADD COLUMN takes ACCESS EXCLUSIVE but gets it instantly on an idle twin — so it measured SAFE, hiding that in production it waits behind your longest open transaction while every query queues behind it. The fix: that verdict now carries the acquisition_not_simulated qualifier, and hostile mode holds a real transaction so the acquisition wait is measured, not assumed.

The projection once invented a freeze

What broke: an early scale model projected a phantom freeze onto shapes that don't block writes at all, turning a genuinely safe change red. The fix: the projection only ever escalates a measured SAFE to UNSAFE — it never manufactures a number for a non-write-blocking shape, and an ERRORED or INCONCLUSIVE run emits no projection at all. It is labelled an assumption everywhere it appears.

The platform proxy silently downgraded the MCP

What broke: the hosted endpoint's POST /mcp/ was rewritten to a 307 → GET /mcp by the platform proxy, which broke the streamable-HTTP handshake — the remote MCP looked dead through no fault of its own. The fix: an ASGI path-normalising wrapper serves both /mcp and /mcp/ directly, so an agent connects whether or not its client sends the trailing slash.

why this page exists

Every fix above made the engine say less — fewer confident greens, more honest qualifiers. A crash-test tool is only worth running if it would rather say "I didn't test that" than tell you a comfortable lie.

Honest limits

Where the rehearsal stops matching reality.

Brace is a rehearsal, not a guarantee. The trust mechanic is naming exactly where it stops being true.

Postgres only — on purpose

Brace commits to one database's truth. Its entire mechanism is Postgres-specific: the lock ladder (SHARE / ACCESS EXCLUSIVE / SHARE UPDATE EXCLUSIVE), lock_timeout and the 55P03 error, and reading contention straight out of pg_locks. Other engines lock differently and would need a different engine, not a config flag. Doing one database exactly right beats doing several approximately.

The twin is not your whole production system

A twin is a single Postgres database under a synthetic write mix. It does not model read replicas and replication lag, autovacuum pressure, or a cold cache — all of which shape how a migration actually behaves in production. It proves the mechanism (a blocking lock kills waiting writers); it does not reproduce your specific traffic shape, pooling, or retry logic.

The projection is a linear model

Scale projection multiplies a measured freeze by a row-count ratio. It has not been validated across arbitrary index types, fill factors, or hardware, and it is labelled an assumption wherever it appears — never a second measurement.

Sequences run; rollout coordination doesn't

Brace measures a single statement or an ordered multi-statement sequence on one twin (up to 10 steps), each step measured in its own window. What stays out of scope is the application-level coordination around a migration — dual writes, batched backfills, feature-flag cutover — and long transactional DDL blocks. Brace tells you what each DDL step does to live writes; it does not orchestrate the rollout around it.

The public lab is synthetic

Row values, timestamps and label statements in the stock seed are generated, not sampled from a real dataset, so planner behaviour can differ from a production table with real data skew even at the same row count. If that is what you're rehearsing, bring your own dump.

Two more honest edges

  • CREATE INDEX CONCURRENTLY is reported safe for write-freeze purposes because it doesn't take the blocking lock — measured, not assumed. What Brace does not simulate is a CONCURRENTLY build failing partway and leaving an INVALID index that needs a manual retry.
  • The measured numbers on this page reflect one run on one hardware profile. Absolute seconds will differ elsewhere; the relative ordering is the part expected to generalize.

Bring your own migration.

Paste the one you are nervous about. It runs on a dummy, not on your users.