ARIADNE

A run, start to finish

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.

warehouse
Postgres, 378,817 census person records, California 2018, built by dbt
models
income-classifier v3 and workforce-classifier v3, both in the MLflow registry at stage Production
catalog
DataHub v1.5, lineage ingested by the postgres, dbt and mlflow connectors, nothing hand written
read through
the DataHub MCP server and the Agent Context Kit, checked against each other on every walk
run
28 July 2026
01

The change

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
02

Retrain, register, promote

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.

03

Ask the governance check first

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.

04

Ask what the model can rebuild

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 all
05

Follow the column back

It 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.

the same column walk, in the demo
06

Same graph, different statute

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.

07

File it where somebody will see it

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 DataHub
08

Now take the tags away

Everything 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.

09

Ask it as a question instead

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.

both agents, and the check that the two surfaces agree

Why that column

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.

candidatewhat it isaloneadds
PUBCOVany public health coverage0.7020+0.0544fires
HINS4Medicaid, whose survey question names disability0.6650+0.0379fires
SSIPsupplemental security income0.5923+0.0261silent
HINS3Medicare, disability driven below 650.5948+0.0239silent
VEHvehicles available, the guess0.6201+0.0096silent

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 source

Reading this honestly

the proof, one page per part

Every DataHub surface this used

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.

index

All surfaces

Every part of DataHub, what it was used for, and whether it is live.

8 surfaces
mcp server

MCP

The official server as a child process over stdio, tools discovered not assumed.

6 tools discovered
agent context kit

Agents

Blast radius before a change, root cause after one, over either transport.

3 of 3 walks agree
context graph

Graph

Lineage ingested by three connectors, policy packs, and where the column entered.

7 hops, column level
incidents api

Incidents

Findings written back into DataHub and read out again through the front end.

18 raised, two regimes
datahub skills

Skill

An ML impact skill in the registry format, carrying the four traps we hit.

4 failure modes encoded
research

Register

Proxy hypotheses declared with sources before measurement, most of them refuted.

2 of 5 survived