Desired-state reconciliation for DataHub

Your catalog knows what is.
Upstream decides what should be — and closes the gap.

You declare the estate you want in one file. Controllers read the DataHub graph through DataHub's own Skills, reason over cited evidence and past incidents, price every change before proposing it, and — only with your approval — write what they learned back into DataHub.

Apache-2.0 · runs against the DataHub OSS quickstart · bring your own model key

estate.yaml tier-1
tiers:
  - name: tier-1
    match:
      - domains: ["Data Platform Team"]
      - name_patterns: ["fct_*", "dim_*"]
        platforms: ["dbt"]
    requirements:
      require_owner: true
      require_glossary_term: true
      min_assertions: 1
      freshness_sla_hours: 24
      max_open_incident_hours: 4

policies:
  actuation: ratify            # never silent
  max_proposals_per_run: 25
  reliability:
    min_confidence_to_remediate: 0.75
    immunize_on_resolve: true

Selectors resolve to URNs through DataHub search, so the spec follows the estate as it grows instead of naming assets one by one.

02

DataHub is the system of record. Upstream is the system of intelligence.

Nothing here replaces the catalog. Upstream reads it, decides what the metadata means, and returns the conclusion to the same graph — so the value lands in the product your team already opens.

DataHub

Stores what is

The metadata graph, and the retrieval surface Upstream is built on. Every read goes through it.

graphlineageownership glossaryassertionsincidents timelinestructured properties

Upstream

Decides what should be

A declared spec, controllers that reconcile toward it, and a human gate in front of every write.

conformanceinvestigationmemory simulationriskproposals ratificationimmunization
03

Thirty-one lines is the entire configuration surface.

Three tiers, each with the requirements it must meet. Everything else is inferred from your graph. A requirement is a pure function over the metadata — no model participates in scoring, so the same estate always scores the same number.

tier-1

Zero tolerance

Order-entry analytics the business reports on. Owner, description, glossary term, governance properties, an assertion, a 24-hour freshness SLA, and no incident open longer than four hours.

tier-2

Owned and column-tested

Staging and intermediate dbt models. Owned, described, and carrying at least one DATASET_COLUMN assertion — the kind that catches a bad value, not just a late table.

raw

The vendor boundary

Contracted and monitored, or drift arrives silently. A schema contract here is what turns a vendor's rename from a quiet null into a failing check.

04

The gap, as one number that moves.

162 checks across 26 assets, scored by check rather than by asset so the dial moves smoothly as gaps close. This is the real report from examples/conformance/ — before the reconciliation run, and after it.

0%

Estate conformance

+32.7% · 53 checks fixed

tier-1 16 assets · 128 checks 59%
tier-2 2 assets · 10 checks 40%
raw 8 assets · 24 checks 8%
overall 26 assets · 162 checks 50%

The raw tier scores 8% because the vendor feed boundary carries no schema contract, no owner and no test. That is not a scoring artefact — it is the reason the drift below propagates silently.

05

Measuring drift is a report. Closing it is the product.

Every proposal is grounded and priced before anyone sees it. An owner comes from git history, the lineage graph, a sibling asset or the domain — never from a guess. A description is written from the columns and upstreams the read path already returned. Proposals with no simulated benefit are dropped rather than shown.

observe

50%

Score the graph against the spec. Deterministic, no model.

plan

45

Proposals, each grounded in graph facts and priced by simulation.

ratify

human

A real pause — LangGraph interrupt() with a checkpointer.

actuate

45

Writes, each logged to a ledger that make reset can undo.

verify

83%

Re-score against the same checks. The claim is measured, not asserted.

Projected +32.7% → measured +32.7%

The what-if and the what-is run through the same checks.py over the same context object, so the number on the ratify screen is the number you get. A disagreement here is a bug, not a rounding difference.

06

A vendor renamed a column. No job failed.

A staging cast turned the revenue mart 85% NULL and every dashboard kept rendering. DataHub's Timeline API reports the rename as a single MODIFY event — not a REMOVE plus an ADD. That one event is the whole case, and the correlator is built on it.

investigate urn:li:dataset:(urn:li:dataPlatform:dbt,demo.main.fct_revenue_daily,PROD)
8 skills · 9 invocations 2 SDK fallbacks, logged 18 pieces of evidence 6 hops walked 2 queries run · 1 refused budget: tools 48/60 · queries 2/4 · llm 2/25
07

Every conclusion is one click from the graph fact under it.

What Upstream concluded is set in sans. What it quoted from DataHub is mono, and blue. A hypothesis that cites no evidence is dropped in code, before it reaches a person.

timelineorder_details · 2026-08-05 17:27 UTC · MODIFY discount_amount — "a forwards & backwards compatible change due to renaming of the field 'discount_amount to discount_amt'"
timelineorder_details · 2026-08-05 17:28 UTC · MODIFY discount_amt — renamed back to discount_amount
lineageorder_details [duckdb] is 6 hop(s) upstream · 0 assertion(s), 0 failing
lineagestg_vendor_orders [dbt] is 4 hop(s) upstream · 3 assertion(s), 0 failing
query1 row: {'source_null_rate': 0.81635, 'target_null_rate': 1.0}
query1 row: {'discount_total_null_rate': 1.0}
refusedBinderException: Referenced column "discount_amt" not found in FROM clause — the refusal is itself evidence, and it is logged
assertionassert_discount_coverage is FAILURE
contextfct_revenue_daily · 6 assertions (1 failing) · 8 upstream · 1 downstream
memory0 match(es) · cold start · the memory signal contributes 0.00 to confidence rather than a flattering default
08

The confidence number is computed, never reported by the model.

Five orthogonal signals, one weighted sum, plus a hard gate: a hypothesis with no timeline event, query result or run record behind it is multiplied by 0.6, because a story without a hard artifact is a story. A judge who distrusts language models can still audit this number.

evidence × 0.60 0.91
memory × 0.10 0.00
timeline × 0.10 1.00
forensic × 0.10 1.00
simulation × 0.10 1.00
hard artifact gate 1.00

and eight more, for priority

Risk is arithmetic too

business criticality 0.20 · assertion health gap 0.15 · historical fragility 0.15 · lineage depth 0.10 · governance violations 0.10 · incident similarity 0.10 · business impact 0.10 · controller uncertainty 0.10. One dict, one explain(), no model.

bounded by policy

A run cannot spin

Six lineage hops, six forensic queries, one bounded retry, 25 proposals per run. The budget is printed in the trace as it is consumed, and the limits live in the spec where a reviewer can read them without reading the code.

09

Not knowing is a result, and it is written like one.

Run the same command against a healthy asset and it does not manufacture a story. It stops at 0.70, claims nothing, and names the single probe that would settle the question. Abstention is a first-class outcome here, not an error state.

examples/traces/abstain.mddim_customers
## No cause claimed

The evidence did not support a conclusion above the remediation
threshold, so none is asserted.

Next probe: Check the ETL job logs for order_details to dim_customers
transformation around 2026-08-05 17:28 UTC to confirm mapping failure.

confidence 0.70 · below remediate 0.75 · 20 pieces of evidence

Twenty pieces of evidence and no conclusion. An agent that filled this in would be guessing into a catalog people trust.

10

What it leaves behind, inside DataHub.

Conclusions are not stored in Upstream. They are written back into the catalog your team already opens — so the second failure of this class is cheaper than the first, and the immunization assertions mean there may not be a second run at all.

assertion ×2 Immunization registered on the cause, not the symptom — a schema contract at the vendor boundary, and a not-null on the field that was renamed.SCHEMA_CONTRACT · urn:li:assertion:upstream-4934e20156aee96647d65b9d
FIELD_NOT_NULL discount_amount · urn:li:assertion:upstream-ada7c9fd815493a0ac50c5a0
written
incident Raised with the full RCA, the mechanism, the confidence decomposition and the evidence ledger — then resolved, because the fix was verified.urn:li:incident:04108893-2b0e-4124-8d01-593544e6213f · P2 · mttr 1 min resolved
documentation A 97-word runbook attached to the upstream cause, where the next person — and the next run's memory read — will actually find it.Runbook: vendor schema drift · institutional memory link on order_details written
structured properties What this estate has now lived through, on both assets — so the next run's risk model does not start from zero.fragilityScore, knownFailureModes, lastRootCause, mttrMinutes → fct_revenue_daily, order_details written
tag The failure class, on both the symptom and the cause, so it is filterable in the DataHub UI.upstream:vendor_schema_drift written

before any of it was written

A patch, not just a paragraph

Two files changed — the staging cast and a contract test — git apply --check confirming it applies cleanly, and 13/13 tests passing against the patched model. The diff is committed under examples/pull-requests/. Upstream proposes the fix; it does not merge it.

10/10 write-backs

Every write is reversible

Each one is appended to a ledger with its previous value, so make reset returns the estate to its seeded state. A demo you cannot re-run is a demo you cannot trust.

11

An operator surface where the frontend computes nothing.

Five screens over a live HTTP API. Filtering, ordering, paging, counting, pricing and even the empty-state copy are backend projections — the same functions that generate the committed artifacts, so the live payload and the recorded one cannot drift. React renders what it is handed and decides nothing.

route

1 line

Validates and delegates. A second statement is logic in the wrong layer.

controller

decides

What a verdict means, read off the record rather than the request.

writer

one table

The same dispatch the controller calls at its own gate.

receipt

typed

What was written, to which aspect, and the urn DataHub returned.

stream

verbatim

The stored receipt, sent byte for byte. Resumable by sequence.

One schema for the write path and the live one

Every mutation answers with a MutationReceipt. That same object is the line appended to the journal and the frame GET /api/events broadcasts — so there is no second schema and no translation layer. A receipt names the screens it invalidated, and each one re-asks its projector rather than patching state by hand.

approving is not writing

And the receipt says which

A proposal is written if it carries a drafted payload and the estate policy allows it. Otherwise the decision is recorded, naming the reason — a draft pull request Upstream never merges, a row read from a report that kept no payload, or a proposal already decided.

partial is a real answer

200, never 500

DataHub has no transaction across aspects, so a batch is not atomic. A half-written batch reports which writes landed and which failed — discarding the successful ones is the single outcome an operator cannot recover from.

12

Every fact reaches the agent through DataHub's own Skills.

Eight skills over five distinct tools, executed against a live OSS quickstart and read back. When a tool is unavailable the SDK fallback is used and logged — the trace prints which facts arrived the long way, because a read path that hides its own failures is not a read path.

asset-search

search · 43 ms · 10 results

available

lineage-retrieval

get_lineage · 336 ms · upstreams

available

assertion-retrieval

get_dataset_assertions · 32 ms

available

quality-retrieval

list_schema_fields · 77 ms · 18 fields

available

governance-retrieval

get_entities · structuredProperties, health

available

glossary-retrieval

get_entities · glossaryTerms

available

documentation-retrieval

get_entities · description

available

ownership-retrieval

get_entities · empty on this asset

empty, reported

contributed back

reconcile-estate, as a DataHub Skill

The conformance scorer is packaged as a Skill any DataHub agent can call: give it a spec, get back the ranked, remediable gap. It reads. It changes nothing.

reproducible

Every model call is on a cassette

LLM_MODE=replay reproduces a run with no API key and no network. Replay reproduces a run, not a question — a re-seeded estate asks something different, and says so rather than pretending.

13

What it refuses to do.

The restraint is the feature. Anything that would put a guess into a catalog people trust is left for a human, and named.

28

violations survive the run, on purpose

16 freshness requirements need an evaluation, not a declaration. 8 assets have no owner anywhere in the graph to infer from. 4 ask for a judgement — a business term, a retention period — that belongs to a person. Each one is listed with its reason.

0

unapproved writes, by design

The gate is a real pause, not a confirmation dialog: LangGraph interrupt() with a checkpointer, and a test that fails if it ever becomes a pass-through.

0

models involved in scoring

Conformance, risk and confidence are pure functions over graph facts. The model writes prose and proposes hypotheses; it never reports a number that decides whether Upstream may act.

1

bounded retry, then it stops

One more forensic pass is allowed when the evidence is thin. After that the run abstains and names the probe a human should take. A stubborn failure cannot turn into an unbounded loop.

14

The whole demo, from empty to red dial to green.

Python 3.11, Docker with 8 GB, one model key. The estate is DataHub's own sample data — loaded, mirrored into DuckDB for forensic SQL, then broken on purpose by a deterministic scenario library.

01make upStart DataHub — version-pinned, host-port-safe.
02make seedLoad the sample estate, mirror it, add run history, decay it.
03make scoreConformance of the graph against estate.yaml. 50%.
04make break scenario=vendor-driftRename the vendor column and re-ingest, so DataHub sees it.
05make investigate urn="urn:li:dataset:(…,fct_revenue_daily,PROD)"Diagnose the silent failure. Confidence 0.75, with the ledger.
06make reconcilePropose, price, pause for ratification, write, re-score. 83%.
07make resetUndo every write this system made, back to the seeded estate.

Every other agent reads your catalog. Upstream leaves it healthier than it found it.

50 → 83%estate conformance, one session
0.75 → 0.85confidence, earned by a verified fix
0unapproved writes, by design