Premortem

how it breaks, before you merge

A schema-change rehearsal agent for DataHub. Starting from Impact Analysis, it reads warehouse SQL from query history and forecasts how each consumer breaks — HARD, SOFT, UNKNOWN, or CLEARED — then repairs what it can, gates the PR, notifies owners, and writes the forecast back into the catalog.

1.00
HARD precision
(Premortem binder · n=15)
0.00
decoy false alarms
(Premortem binder · n=6)
0.97
accuracy
(39/40 · truncated)

Reproduce in under a minute — offline, no live catalog required.

pip install -e ".[dev]"
pytest -q
python eval/run_eval.py

Who it is for

Primary user: a data / analytics engineer about to merge a column rename — “Can I ship this on Friday?” Secondary: a platform engineer who later opens the dataset and inherits the forecast. Premortem composes with DataHub via MCP; it is not a UI fork and not a hosted catalog you must stand up to judge the claim.

Customer journey: PR trigger, Impact Analysis today, ask the agent, per-query forecast, write-back to the Quality tab.
Five steps from trigger to write-back (persona path). After the forecast: repair · gate · notify — see product-workflow and the numbers section below. Live path: bring-your-own DataHub with query history on the subject (stock Quickstart needs seeding — that is why the demo seeder exists). Measurement path: frozen eval on this page’s reproduce block. CLEARED is the named subset of UNAFFECTED.

What Premortem does

Full path in seven beats — from DataHub Impact Analysis through forecast, repair, gate, notify, and catalog write-back. Binding is the differentiator inside the forecast; the verbs after it are the product.

Product walkthrough Rehearse → bind → refuse when unsure → repair → gate → notify → write-back

Step 1 of 7 — Start from DataHub

order_statusorder_state on ORDER_HISTORY

Impact Analysis DataHub already knows 3 downstream dependents
Still missing what Premortem adds how each consumer breaks

Lineage lists what is connected. Premortem rehearses the rename against real query history before you merge.

The binding problem

Two tables carry a column called order_status; only one of them is the subject of the change. Name matching flags the wrong one as breaking; resolving what the reference binds to clears it.
Differentiator in one picture. order_status exists on two tables. Name matching flags this query as breaking; Premortem binds the alias to shipments, sees it is not the subject table, and clears it. That distinction took decoy false positives from 0.50 to 0.00.

A real forecast, rendered

Sample output from the constructed demo instance (not interactive) — rename order_statusorder_state on ORDER_HISTORY, Impact Analysis baseline 3 downstream dependents.

HARD, SOFT, UNKNOWN, and CLEARED each illustrated with a real query shape.
Verdict taxonomy — remediation blast radius, not “breaks vs still runs.” CLEARED is the named subset of UNAFFECTED (same-named column binds elsewhere).
Schema rehearsal: order_status → rename to order_state Impact Analysis baseline: 3 downstream dependents
Sample output · demo instance · not a live form

HARD (5)

HARD

emitter_hard_where

SELECT order_id FROM order_history WHERE order_status = 'COMPLETE'
HARD

emitter_unknown_bare

SELECT o.order_id FROM order_history o JOIN customers c ON o.customer_id = c.customer_id WHERE order_status = 'OPEN'
HARD

hard_qualified_join

SELECT o.order_id FROM order_history o JOIN customers c ON o.customer_id = c.customer_id WHERE o.order_status = 'OPEN'
HARD

hard_where_order_status

SELECT order_id, customer_id FROM order_history WHERE order_status = 'COMPLETE'
HARD

unknown_bare_join

SELECT o.order_id, c.customer_id FROM order_history o JOIN customers c ON o.customer_id = c.customer_id WHERE order_status = 'OPEN'

SOFT (2)

SOFT

emitter_soft_select

SELECT order_status, order_id FROM order_history
SOFT

soft_select_order_status

SELECT order_status, order_id, order_total FROM order_history

UNKNOWN (1)

UNKNOWN

unknown_bare_two_tables

SELECT o.order_id FROM order_history o JOIN shipments s ON o.order_id = s.order_id WHERE order_status = 'OPEN'

unqualified order_status with 2 tables in scope; needs human/agent

CLEARED (1)

CLEARED

decoy_shipments_order_status

binds to shipments

SELECT s.shipment_id FROM shipments s WHERE s.order_status = 'SHIPPED'

Composition

One agent session registers two MCP servers. Live reads use the DataHub Agent Context Kit (schema, lineage, query history). Premortem’s server runs rehearse_schema_change and returns a forecast, repairs[] (HARD/SOFT patches; else refuse), and write_payload. Owners (who to warn) ride in the forecast markdown. The host applies the payload as-is: tag + description via DataHub MCP mutations; the custom assertion (platform=premortem) via GraphQL — Premortem’s only direct catalog write is that assertion (OSS MCP Data Quality tools are DISABLED — #151). Result on the Quality tab: assertion + premortem_forecast tag + description — no recomputing.

One agent session with two MCP servers: DataHub supplies schema, lineage and query history; Premortem returns forecast, repairs, and write_payload; assertion lands via GraphQL (#151).
DataHub supplies the context. The binder is catalog-agnostic (no LLM); DataHub connects through CatalogClient. Premortem’s only direct catalog write is the GraphQL assertion (#151); everything else flows through DataHub’s own tools.

Architecture

Layered architecture — a catalog-agnostic binder core (no LLM) that composes with DataHub via one CatalogClient protocol (Kit / GraphQL / Fake). Classify and repair share the same binder.

Layered architecture: CLI, Premortem MCP, and eval harness over a catalog-agnostic core; CatalogClient backends Kit, GraphQL, Fake; DataHub at the edge.
The binder does not import DataHub or an LLM — DataHub plugs in at the edge — so python eval/run_eval.py can measure the core offline. Classify and repair share analyze_bindings; swap the catalog backend and the core never changes.

The numbers

HARD precision: every-dependent-breaks 0.40, substring grep 0.44, Premortem 1.00. Decoy false-alarm rate: every-dependent-breaks 1.00, substring grep 0.83, Premortem 0.00.
Measured against the same frozen forty queries. Full table in eval/RESULTS.md.

C+A is net-negative: the heuristic adjudicator binds 3 of 9 classifier UNKNOWNs with bind accuracy 0.00 — every bind wrong. It ships disabled (adjudicate=False is the live default).

That component was replaced with an LLM adjudicator that runs only on the genuinely ambiguous residue (q05, q17, q29 — bare column, ≥2 tables that each carry it). It correctly declined all three rather than guessing; their gold is already UNKNOWN, so there is no measurable lift on the frozen eval and binder-only remains the live default. Responses are committed in eval/llm_adjudicator_cache.json so python eval/run_eval.py reproduces the B2 row with no API key.

Named C miss: q39 (hardsoft) in the subquery stratum — documented limitation, not tuned against the frozen labels. Browse it in the eval explorer.

Repair round-trip (does not touch the freeze): python eval/run_repair_roundtrip.py22/22 eligible patches pass; 18 refused (CLEARED / UNKNOWN / SELECT * / ambiguous / no subject bind). Samples: examples/patches/. Merge gate: premortem gate (default --fail-on hard,unknown).

Against foreign SQL

Every frozen-eval number above comes from a corpus I wrote. To test generality, I pointed the binder at mozilla/bigquery-etl (commit 717b6a1) — 118 queries that mention clients_daily and client_id. There are no gold labels, so there is no accuracy, precision, or recall claim — coverage and failure modes only.

84.75%
parse rate
(18 Jinja / BQ / sqlglot gaps)
86%
UNKNOWN
(corrected distribution)
11%
CLEARED
(known non-subject tables only)

This run measures missing-schema behavior. With schemas present (a real DataHub-connected warehouse), narrowable references bind instead of abstaining; the 11% CLEARED here are qualified references to known non-subject tables. Pointed at a codebase where it lacks the schemas, Premortem says needs a human rather than guessing. An earlier unsound CLEARED rule had falsely cleared 74% of rows despite a 0.09 table-resolution rate — that defect is fixed and named in the report.

Limitation: 6 of the 13 CLEARED resolve to clients_daily while the subject is clients_daily_v6. Correct at the table level, but if that name is a view over the subject, a rename propagates through it. Premortem reasons about tables, not view expansion.

Full write-up: real-world run (HTML) · source markdown on GitHub · raw JSON

Upstream contributions

Four issues filed against DataHub from this build (Quickstart v1.5.0.6, mcp-server-datahub 0.6.0):