Somebody adds one column to a feature table used by a model that decides something about a worker. Every test passes. Accuracy improves. This is what each check said, in the order it was asked, with the output left as it came out.
One line in a dbt model. The comment is the one that was actually written.
warehouse/models/marts/workforce_features.sql
household_size,
census_region,
vehicles_available,
+ -- benefits admin asked for the coverage flag so onboarding can tell who
+ -- already has cover and skip the enrolment prompt. no health detail,
+ -- just the flag
+ public_coverage_flag,
case when hours_worked_per_week >= 35 then 1 else 0 end as works_full_time
Nothing in the training script mentions the new column. It takes every column that is not the key or the label, so the column became a feature by itself.
python ml/train.py --feature-table analytics_marts.workforce_features \
--label works_full_time --model-name workforce-classifier
training on 244,328 rows, 7 features
age, education_level_code, marital_status_code, household_size,
census_region, vehicles_available, public_coverage_flag
accuracy 0.7310 roc_auc 0.7811
registered as workforce-classifier
Created version '3' of model 'workforce-classifier'.
Accuracy was 0.6955 before the change and 0.7310 after. A three and a half point gain. On every dashboard in the building this change is an improvement.
It walks column lineage and reads governance tags, under US employment law. Watch what it does not say.
python tools/sentinel.py --policy employment_us
== restricted attribute reaches a deployed model ==
under Title VII of the Civil Rights Act, the ADA, and the ADEA
watching columns tagged protected_attribute
workforce-classifier_3 (mlflow_production)
trained on warehouse.analytics_marts.workforce_features, 10 columns, 1 prohibited, 1 examine
prohibited age tagged on the feature itself
age, 29 USC 623, ADEA
examine marital_status_code tagged on the feature itself
marital_status, Not a federally protected class in employment
Two findings, both of which were already there yesterday. Nothing about
public_coverage_flag. The check is not broken and it is not wrong.
Insurance coverage is genuinely not a protected characteristic, so a check that
reads labels has nothing to report.
This one fits a model to recover each protected attribute from the features the deployed model does hold, with the attribute itself excluded, and compares against the last recording.
python tools/exposure.py check --model workforce-classifier
== reconstructability of a protected attribute moved ==
workforce-classifier v2 recorded 2026-07-27T13:30:31+00:00
workforce-classifier v3 recorded 2026-07-28T08:34:06+00:00
features gained: public_coverage_flag
model accuracy 0.6955 to 0.7310 (+0.0355)
disability: With a disability against Without a disability
0.7489 to 0.7883 (+0.0394, 12 times the measured noise of 0.0033)
the deployed model gained public_coverage_flag between these recordings,
which is where to look first
One finding. Every other attribute held still, and the silence is what makes the one that moved worth reading. The threshold is measured rather than chosen: four times the spread observed across refits, floored at 0.02.
why this column was a candidate at allIt is untagged where the model reads it. The question is whether the warehouse knew anything about it further upstream.
python tools/trace.py column warehouse.analytics_marts.workforce_features \
public_coverage_flag
warehouse.analytics_marts.workforce_features.public_coverage_flag
1 hop warehouse.analytics_marts.dim_person.public_coverage_flag [personal_data]
2 hops warehouse.analytics_staging.stg_person.public_coverage_flag
3 hops warehouse.public.raw_person.pubcov
Three hops to pubcov, the real census variable name, and a governance
tag one hop back that the feature table never carried. Every edge here came out of
a DataHub connector reading dbt, Postgres and MLflow.
Nothing changes except which policy pack is named. The EU AI Act asks for examination of bias affecting fundamental rights rather than listing forbidden columns, so it watches personal data too.
python tools/sentinel.py --policy eu_ai_act
== restricted attribute reaches a deployed model ==
under Regulation (EU) 2024/1689, Annex III high risk systems
watching columns tagged protected_attribute, personal_data
workforce-classifier_3 (mlflow_production)
trained on warehouse.analytics_marts.workforce_features, 10 columns, 3 examine
examine age tagged on the feature itself
examine marital_status_code tagged on the feature itself
examine public_coverage_flag dim_person.public_coverage_flag, 1 hop back
in scope because it carries personal_data
The same column, found a second time by a completely different route. One route needed no labels at all. The other needed no statistics. Getting the same answer twice by different means is the closest thing to corroboration this problem allows.
Nothing is written without --raise.
python tools/incident.py --model workforce-classifier \
--policy employment_us --raise
== 3 finding(s) for workforce-classifier under Title VII, the ADA, and the ADEA ==
[filing] CRITICAL age reaches workforce-classifier_3
on urn:li:dataset:(urn:li:dataPlatform:dbt,warehouse.analytics_marts.workforce_features,PROD)
raised urn:li:incident:ac291971-4ca5-41b1-9acb-0c58f9ed511a
[filing] LOW marital_status_code reaches workforce-classifier_3
raised urn:li:incident:305c0844-755e-4fdb-a782-8b9c653be4da
[filing] HIGH disability (With a disability) became easier to rebuild
raised urn:li:incident:107fdb6a-680d-45d7-a670-122736c40b50
Read back through the DataHub front end, logged in as a user:
POST /api/v2/graphql { dataset(urn: "...workforce_features") { incidents } }
{"data":{"dataset":{"name":"workforce_features","incidents":{"total":3,
"incidents":[
{"title":"disability (With a disability) became easier to rebuild from
workforce-classifier","priority":"HIGH","status":{"state":"ACTIVE"}},
{"title":"marital_status_code reaches workforce-classifier_3",...},
{"title":"age reaches workforce-classifier_3","priority":"CRITICAL",...}]}}}}
Filed on the feature table, not the model. DataHub v1.5 rejects an incident on an mlModel urn at the entity layer. The table is the better home anyway: it is where the column entered and where a fix goes.
all eighteen incidents, read back from DataHubEverything above leaned on somebody having tagged the warehouse. So here is the raw census extract, where the column names are codebook abbreviations and no tag has ever been written.
python tools/identify.py public.raw_person
== what does public.raw_person hold ==
no tags read, no lineage walked. names and values only.
identified rac1p race name and values agree
identified dis disability name and values agree
identified sex sex name and values agree
identified cit citizenship name and values agree
identified mar marital_status name and values agree
identified nativity nativity name and values agree
identified agep age name only
identified pobp place_of_birth name only
identified anc1p ancestry name only
ambiguous pubcov disability, nativity, sex
several attributes share this code set and the name does not
choose between them
escalate to reconstruct.py, which does not need the name
9 identified, 3 ambiguous, 12 with nothing to say
Nine of nine, from names and value distributions alone. And the coverage flag is still ambiguous here, because it is coded one and two exactly like sex and disability. The cheap rungs say so and hand it to the expensive one, which is the rung that found it in step 4.
Everything above is a check that runs. This is the same evidence asked for as a question, by an agent reading the catalog through DataHub's own MCP server rather than through any query of ours.
python tools/rootcause.py --model workforce-classifier --policy eu_ai_act --via mcp
== why did workforce-classifier move ==
read through mcp, under Regulation (EU) 2024/1689, Annex III high risk systems
comparing version 2 (2026-07-27T13:30:31) with version 3 (2026-07-28T08:34:06)
accuracy went up, 0.6955 to 0.7310 (+0.0355)
so nothing that watches model quality had a reason to object
1 measurement moved beyond noise
disability (With a disability against Without a disability) 0.7489 to 0.7883 +0.0394, 12x the noise
the deployed model gained 1 column between those two moments
public_coverage_flag
examine, in scope by tag, Article 10(2)(f), Article 10(2)(g), Article 9, Annex IV
enters at postgres:raw_person, 7 hops back
called pubcov where it enters, so a search by name would not have found it
defined in models/marts/dim_person.sql
defined in models/marts/workforce_features.sql
defined in models/staging/sources.yml
defined in models/staging/stg_person.sql
cause: public_coverage_flag
A column, a file, a moment and a rename, in one answer. The rename is the part a
person could not have searched for: the column is called
public_coverage_flag where the alarm fired and pubcov
where it enters the warehouse.
The first attempt guessed. A commute field, vehicles per household, seemed a plausible stand in for disability. It moved the number +0.0096, well under the threshold, and the alarm stayed silent, correctly. So the candidates were written down first with a source against each, then measured on 244,328 rows over three seeds. Only two of the five that could be tested survived.
| candidate | what it is | alone | adds | |
|---|---|---|---|---|
PUBCOV | any public health coverage | 0.7020 | +0.0544 | fires |
HINS4 | Medicaid, whose survey question names disability | 0.6650 | +0.0379 | fires |
SSIP | supplemental security income | 0.5923 | +0.0261 | silent |
HINS3 | Medicare, disability driven below 65 | 0.5948 | +0.0239 | silent |
VEH | vehicles available, the guess | 0.6201 | +0.0096 | silent |
Medicare is the useful failure. It had the better source of the two eligibility rules and it is silent anyway, because the features already present carry most of what it knows. A published association is a hypothesis about your warehouse, never a fact about it. It stays in the register as refuted rather than being deleted.
the register, with every sourcedisability_code means disability came from a declared policy file. Lineage carries that one fact everywhere after, so you have to be right once, but once is not zero.The run above is the argument. These are the parts of DataHub it was built on, each with the output it actually returned on this instance.
Every part of DataHub, what it was used for, and whether it is live.
8 surfaces mcp serverThe official server as a child process over stdio, tools discovered not assumed.
6 tools discovered agent context kitBlast radius before a change, root cause after one, over either transport.
3 of 3 walks agree context graphLineage ingested by three connectors, policy packs, and where the column entered.
7 hops, column level incidents apiFindings written back into DataHub and read out again through the front end.
18 raised, two regimes datahub skillsAn ML impact skill in the registry format, carrying the four traps we hit.
4 failure modes encoded researchProxy hypotheses declared with sources before measurement, most of them refuted.
2 of 5 survived