Decision records for DataHub agents
An agent leaves a note saying it was safe to delete a column. Months later the note is still there, and nobody can tell whether it was ever true.
The note said nothing was using this table. But what a company knows about its own data changes constantly — something may have started using that table an hour before the agent looked, or an hour after. The note doesn't say which version of the world it was describing. So nobody can tell whether the agent was wrong, or whether it was right about a world that had already moved.
That is the whole problem this project fixes. Every time the agent looks something up, we record exactly which version of the company's records it saw — not just what it saw. The note now reads “nothing was using it, according to version 461, recorded at 22:24:56”, and the question is answerable. And when we cannot establish which version the agent saw, we say so and refuse to vouch for the decision, rather than issuing a clean-looking approval.
Start here
- The repository — source, tests, and the full README.
- Run it
yourself —
pip install -e '.[dev]'thenpytest -q. 71 of the 106 tests need no DataHub at all. - The negative control — one command, no DataHub, exits non-zero if the refusal stops holding. The fastest way to check this page's claim instead of taking it.
- Recorded output — transcripts and certificates from the live run, not mock-ups.
The words this page uses
DataHub vocabulary in one place, so nothing below depends on knowing it already.
- DataHub — an open-source catalogue of what data a company has, where it came from, and who owns it.
- Aspect — one named piece of what DataHub knows about a thing: its lineage, its owners, its documentation. Each aspect is versioned independently.
- Revision — one numbered version of an aspect. Version 0 means “whatever is current”; 1…N are the archived versions, 1 being the oldest. This is the coordinate the whole project turns on.
- Lineage — which datasets feed which. “Nothing reads from this table” is a lineage claim.
- URN — DataHub's unique identifier for a thing, such as
urn:li:dataset:(urn:li:dataPlatform:snowflake,…). - MCP — Model Context Protocol, the standard interface an AI agent uses to call tools. The agent here reads DataHub through DataHub's own MCP server rather than a private back door, which is why the gap it runs into is a real one.
- institutionalMemory — the DataHub aspect holding links and notes attached to a dataset. Certificates are written back here, so they show up in the product's own Documentation tab.
- Capability class (C0–C3) — how much a record can actually support, reported as a class rather than a percentage. Defined in full further down.
The same call, decided both ways
The agent asks DataHub for the downstream lineage of a Snowflake table, and drops a column only if nothing reads from it. Between these two runs a pipeline change wires a consumer to that table. Same agent, same call, opposite outcomes — and each record names the aspect revision it was made against.
TIMELINE — one agent, one question, asked twice #0 Read downstream lineage for b2fd91.order_entry_db.order_entry.orders — through the real MCP server #1 Bound that read to upstreamLineage v941 (lastObserved=1786026377925) #2 Certified C2 — every deciding read named a revision #3 DECISION: admit — nothing reads promo_code at v941 #4 Proposed, NOT applied: DROP COLUMN promo_code ··· the world moves: a pipeline change rewires who reads b2fd91.order_entry_db.order_entry.orders #5 The identical call, seconds later, same MCP server — b2fd91.order_entry_db.order_entry.orders #6 Bound that read to upstreamLineage v942 (lastObserved=1786026380223) #7 Certified C2 — every deciding read named a revision #8 DECISION: reject — a consumer reads promo_code at v942 #9 Proposed, NOT applied: DROP COLUMN promo_code Same agent. Same call. Opposite decisions. Neither log can tell you which world it was made in. Both certificates can. CERTIFICATES === as the agent saw it === outcome: admit revision: v941 (lastObserved=1786026377925) Capability class: C2 proposed change: -- No downstream consumer reads b2fd91.order_entry_db.order_entry.orders.promo_code at the bound revision. -- proposed — not applied by dhdr ALTER TABLE b2fd91.order_entry_db.order_entry.orders DROP COLUMN promo_code; === as it actually was === outcome: reject revision: v942 (lastObserved=1786026380223) Capability class: C2 proposed change: -- b2fd91.order_entry_db.analytics.order_history still reads b2fd91.order_entry_db.order_entry.orders at the bound revision, so the drop is refused and deprecation proposed instead. -- proposed — not applied by dhdr COMMENT ON COLUMN b2fd91.order_entry_db.order_entry.orders.promo_code IS 'deprecated: still read by b2fd91.order_entry_db.analytics.order_history; do not drop until that dependency is removed';
Both certificates say C2, so both decisions are sound. That is the point: neither
run was wrong. They saw different worlds, and only the certificate can tell you which.
Through the real MCP server
The run above reads DataHub's aspect API, which can be pointed at a past instant. This one
goes through mcp-server-datahub itself, which only ever answers about now — so
the world is moved between two live reads instead.
=== no consumer yet === tool: get_lineage (via mcp-server-datahub) value_source: mcp outcome: admit revision: v939 Capability class: C2 === consumer wired up === tool: get_lineage (via mcp-server-datahub) value_source: mcp outcome: reject revision: v940 Capability class: C2 The agent made the identical MCP call both times. value_source=mcp means it decided on the protocol response, not a re-fetch.
value_source: mcp is load-bearing. It means the agent decided on the protocol
response it actually received, not on a value re-fetched afterwards. Between two fetches the
metadata can move, and then the revision on the record is not the revision that decided.
What "Capability class: C2" means
Every certificate above reports a capability class, never a score. The classes are ordinal — each one is the previous plus a stronger question the record can answer:
| class | the record supports asking… |
|---|---|
| C0 | can this decision be reproduced identically? (identity replay) |
| C1 | …and would a stricter policy have changed the outcome? |
| C2 | …and would a looser one? This is what a bound, complete record earns. |
| C3 | …and what about decisions whose own writes change what later decisions read? Never certified — reported as the boundary where deductive evidence stops. |
| none | a deciding read could not be tied to a revision, so nothing is certifiable. Not “C2 with a warning”. |
So C2 above means: the record carries enough evidence to ask what this agent would
have decided under a different policy, bounded to the revision the read was bound to. It does
not mean the decision was correct — dhdr does not adjudicate the drop, only
whether the record supports asking about it later.
A percentage is deliberately never produced. A score averaged over incommensurable kinds of missing evidence manufactures exactly the false confidence this exists to prevent.
What the next reader inherits
A decision that is not published is not inherited. Each certificate is written back to the
dataset's institutionalMemory, so the next agent or engineer finds it without
rerunning anything.
{
"elements": [
{
"createStamp": {
"actor": "urn:li:corpuser:datahub",
"time": 1786026360741
},
"description": "decision=admit against v937 \u2014 class C2",
"url": "https://laolex.github.io/datahub-decision-records/certs/1786026360741.json"
},
{
"createStamp": {
"actor": "urn:li:corpuser:datahub",
"time": 1786026366051
},
"description": "decision=reject against v938 \u2014 class C2",
"url": "https://laolex.github.io/datahub-decision-records/certs/1786026366051.json"
}
]
}
Which part of the record is load-bearing
Remove one captured field at a time and see what the certifier can still claim. An entry that cannot show which part of its own record is doing the work has not demonstrated that the record is necessary.
ABLATION — which captured field is load-bearing removed class still available (none — full record) C2 execution.pure none predicate.id C0 policy.resolved_value C0 candidates.completeness C1 policy.resolution.revision C2 read binding (unbound read) none
The fifth row is not flattering, and it is reported anyway. Deleting the revision from the record costs nothing — the underlying verifier has no concept of a DataHub aspect version, so a record carrying a revision and one missing it certify identically. The revision in the record is documentation for whoever reads it later. It is not evidence, and it does not defend itself.
What is load-bearing is the last row: a read that could not be tied to
a revision collapses the class to none. The contribution here is the
refusal — declining to certify a decision whose world cannot be named — not
the annotation. Both results are pinned by tests.
A record, in full
{
"action": {
"id": "drop_column",
"params_digest": "sha256:58095b73555a42fb25a5cbf38433230df8fb76be6801dcf96bb54a609f3f37d2"
},
"candidates": {
"completeness": "exhaustive",
"items": [
{
"action_id": "drop_column",
"compared_value": 0,
"outcome": "admit",
"predicate_id": "p:61706d6af0ed2414"
},
{
"action_id": "deprecate_instead",
"compared_value": 0,
"outcome": "reject",
"predicate_id": "p:61706d6af0ed2414"
}
]
},
"capture": {
"emitter": "dhdr/0.1.0",
"sdk_version": "0.1.1"
},
"compared": {
"type": "int",
"value": 0
},
"decision_id": "d-6a1094342393",
"execution": {
"clock": null,
"deps_digest": "sha256:ffc12a86aaf4dee008f1ed48594ceb5aaf17fe51e99379b8dc40ee78736e843b",
"path_digest": "sha256:b40f716c6efb58b49797f8056cdf40c59220d7105a01787f0df8dfb08ec5bf17",
"pure": true,
"runtime": "python3.12.3",
"seed": null
},
"outcome": "admit",
"policy": {
"key": "max_safe_consumers",
"resolution": {
"provenance": "bundled",
"revision": "935",
"source": "datahub:urn:li:dataset:(urn:li:dataPlatform:snowflake,b2fd91.order_entry_db.analytics.order_history,PROD)#upstreamlineage"
},
"resolved_value": 0
},
"predicate": {
"expression": "downstream_consumers lte max_safe_consumers",
"id": "p:61706d6af0ed2414",
"operator": "lte"
},
"rcdr_version": "0.1",
"reads": [
{
"key": "downstream_consumers",
"source": "datahub:urn:li:dataset:(urn:li:dataPlatform:snowflake,b2fd91.order_entry_db.analytics.order_history,PROD)#upstreamlineage@v935",
"value_digest": "sha256:5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
}
],
"run_id": "example-then",
"sequence": 0,
"ts": "2026-08-06T14:25:52.811192+00:00",
"writes": []
}
Filed upstream
Two findings from this build went back to the projects they came from:
- mcp-server-datahub#181
— an optional point-in-time parameter for context reads. No read tool accepts a version or
timestamp, and no shipped GraphQL selection requests
systemMetadata, so a read resolves to now and the response carries nothing that dates it. This is the gap the whole project works around. - datahub#18851 —
institutionalMemoryhas no registered patch template, soPATCHfails with a null-template exception while eight other dataset aspects handle it. This is why write-back here is read-append-write rather than an atomic append, which the README states rather than papering over.