ARIADNE
recorded run Every number and every line of output below was produced on a live instance on 27 and 28 July 2026 and is committed in examples/. Nothing here is generated on page load. The DataHub links are live, so the findings can be checked against the catalog itself.
warehouse
analytics_marts
catalog
datahub v1.5.0.6
models watched
2 in production
last run
28 Jul, 08:34 UTC
findings
1 new
step 01, what it is attached to

Ariadne runs where the data already is

The product is an agent inside your own infrastructure, next to the warehouse and next to your catalog. Nothing is uploaded to us and no warehouse credential leaves your network. That is not a privacy flourish, it is the only arrangement in which the measurement in step 03 is possible at all: DataHub holds metadata and never holds rows, and rebuilding an attribute needs rows.

sourcereference estate, live
connected

Reference estate

The instance this recording was made against. Real Postgres, real dbt project, real MLflow registry, 378,817 US Census records for California 2018.

in a deployment

Your DataHub

One address and one access token, read scoped. The catalog already knows which dataset feeds which model, so it is never asked for separately.

in a deployment

Your warehouse

A read-only connection, or a sampled extract of the feature table. Needed only for the rebuild, because no catalog anywhere holds rows.

what one field buysthe whole graph

A tool that asked you to name the model and then name its dataset would be asking you to supply the join by hand. That join is the entire finding. Ariadne asks for the catalog and reads the rest, which is why the chain in step 04 is seven hops long and none of it was typed in.

ariadne connect --catalog $DATAHUB_GMS --token $DATAHUB_TOKEN
  connected   datahub v1.5.0.6
  discovered   2 models in production
               8 datasets across 3 platforms
               dbt, postgres, mlflow joined as siblings
  no model or dataset pairing supplied, none needed
step 02, what it does every night

Most nights it has nothing to say

Each night Ariadne re-measures every production model and appends the result to a history file. The value is not in the night it fires, it is in the nights it does not: a silent record is what makes a single alarm mean something. Below is the actual recorded history, five runs across two models, read from state/exposure.json in the repository.

run history5 runs, 2 models
27 Jul 09:28 income-classifier v2, 11 features 0.8767 baseline
27 Jul 09:31 income-classifier v3, gained puma_code 0.8758 3 findings
27 Jul 13:21 workforce-classifier v1, 5 features 0.6854 baseline
27 Jul 13:30 workforce-classifier v2, gained vehicles_available 0.6955 quiet
28 Jul 08:34 workforce-classifier v3, gained public_coverage_flag 0.7310 1 finding
read the accuracy columnit goes the wrong way

Accuracy rose on both of the runs that changed something, and rose most on the run that produced a finding: 0.6955 to 0.7310, up three and a half points. Every dashboard, every model monitor and every approval gate in the building read that as an improvement and were right to. There was no drift to detect and no degradation to alert on, because nothing degraded. A column arrived.

This is the honest version of the pitch. It is not that monitoring sees nothing. It is that monitoring sees an improvement and says ship it.

step 03, the measurement

What can this model work out about a person?

The model is not asked what it was given. It is asked what it can rebuild. For every attribute a policy pack names, Ariadne trains a separate classifier that tries to recover that attribute from the model's own feature set, with the attribute held out of its own inputs. Sixteen measurements ran. One moved.

workforce-classifier, v2 against v31 of 16 beyond noise
disability with a disability against without 0.7489 → 0.7883   +0.0394, 12x noise
chance, 0.50perfect, 1.00
marital status widowed against married 0.8378 → 0.8516   +0.0138, silent
chance, 0.50perfect, 1.00
sex female against male 0.6025 → 0.6077   +0.0052, silent
chance, 0.50perfect, 1.00
race Black alone against White alone 0.7105 → 0.7114   +0.0009, silent
chance, 0.50perfect, 1.00
why 0.0394 counts and 0.0138 does notthe threshold is measured

The alarm threshold is not a number somebody chose. Each measurement is repeated across three seeds and the threshold is four times the pooled deviation, floored at 0.02. So a noisy attribute has to move further before it is believed, which is why several groups here moved in the same direction and correctly stayed quiet. The largest silent mover was widowed at +0.0138, comfortably under the floor.

The four rows above are a sample. The full sixteen for both versions are in state/exposure.json, including every measurement that found nothing, because an omitted measurement and a clean one look identical after the fact.

python tools/rootcause.py --model workforce-classifier --policy eu_ai_act --via mcp
== why did workforce-classifier move ==
   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
step 04, the cause

One column, added by someone who was right to add it

Benefits administration asked for a coverage flag so onboarding could skip a prompt for people who already had cover. One line of dbt, no health detail, just a flag. Ariadne compares the two training runs, finds the single column the model gained between them, and walks it back through the graph to the point it entered the building.

column walk7 hops, every edge from a connector
  1. raw_person.pubcov census extract via folktables, landed by the postgres connector
  2. stg_person.public_coverage_flag staging, renamed here, which is why a search by name would have missed it
  3. dim_person.public_coverage_flag marts, tagged personal_data at this point and nowhere after
  4. workforce_features.public_coverage_flag the feature table, carrying no tag at all
  5. DataProcessInstance the training run, emitted by the mlflow connector
  6. workforce-classifier_3 mlflow_production, serving
the two things that made it invisibleneither is a bug

It changed its name. The column enters as pubcov and reaches the model as public_coverage_flag. Nobody searching the warehouse for one would find the other, and the rename is perfectly ordinary staging hygiene.

The tag stops one hop short. dim_person carries the personal_data tag. The feature table built from it does not. So a governance scanner that reads tags looks straight at the column the model is using and sees an untagged field. That gap is the whole problem, and it is one hop wide.

python tools/trace.py workforce-classifier --column public_coverage_flag
  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/staging/sources.yml
  defined in models/staging/stg_person.sql
  defined in models/marts/dim_person.sql
  defined in models/marts/workforce_features.sql

  cause: public_coverage_flag
the same column, four statutesonly one of them cares

Which columns are restricted is declared in a policy pack rather than written into the checks, so the same graph and the same tags reach different verdicts depending only on which law is named.

regimewatchesverdict on public_coverage_flag
employment_uscolumns tagged protected_attributenothing to report
ecoaprotected bases under fair lendingnothing to report
canadathe section 3 grounds, by tagnothing to report
eu_ai_actprotected attributes and personal dataexamine, Article 10(2)(f)

The first three are correct to stay quiet. Insurance coverage genuinely is not an enumerated ground in US employment law or in the Canadian Human Rights Act. The EU AI Act asks for examination of bias affecting fundamental rights rather than listing forbidden columns, so it catches it. The difference between silence and a finding is a line of YAML, not a code change.

Canada being quiet by tag is exactly why the two layers are both needed. Disability is an enumerated ground under section 3, and the delta watch in step 03 fires on it under the Canadian pack regardless, because it measures what the model can rebuild rather than reading what somebody labelled.

two columns nobody changedfour regimes, four readings

Nothing about the warehouse differs between these rows. Same graph, same tags, same engine. Only the named statute changes.

attributecanadaecoaemployment_useu_ai_act
age prohibited conditional prohibited examine
marital_status_code prohibited prohibited examine examine

Age is the clearest case. ECOA permits it in a demonstrably and statistically sound credit scoring system, which is why it is only conditional there. The Canadian Human Rights Act enumerates age with no equivalent carve out, so a scoring model defensible under ECOA on that column is not defensible in Canada on the same reasoning. The ADEA agrees with Canada, the EU AI Act asks only that it be examined and documented.

The priority of what gets filed follows from the basis rather than from a guess, so marital_status_code is filed CRITICAL under the Canadian pack and LOW under the US employment pack, off the same finding.

step 05, where the finding goes

A finding is worth nothing where nobody looks

A governance tool that prints to a terminal is a governance tool that gets run once. Every finding is written back into DataHub as an incident on the table it concerns, so it sits next to that table in the catalog the data team already opens. Nothing is written without an explicit flag: the default is a dry run that prints exactly what it would file.

filed on analytics_marts.workforce_features6 active, two regimes
CRITICAL age reaches workforce-classifier_3 under Canadian Human Rights Act urn:li:incident:f42faf68-a926-41ff-a871-a90083559ac7
CRITICAL marital_status_code reaches workforce-classifier_3 under Canadian Human Rights Act urn:li:incident:f7e1996f-9e8c-4782-aba1-e020493914de
HIGH disability (With a disability) became easier to rebuild from workforce-classifier under Canadian Human Rights Act urn:li:incident:63c0b78a-b45d-4dc0-9f15-c6437e203be0
CRITICAL age reaches workforce-classifier_3 under Employment US urn:li:incident:ac291971-4ca5-41b1-9acb-0c58f9ed511a
LOW marital_status_code reaches workforce-classifier_3 under Employment US urn:li:incident:305c0844-755e-4fdb-a782-8b9c653be4da
HIGH disability (With a disability) became easier to rebuild from workforce-classifier under Employment US urn:li:incident:107fdb6a-680d-45d7-a670-122736c40b50
the same fact, twiceCRITICAL in Canada, LOW in the US

marital_status_code reaches workforce-classifier_3 is one fact about the graph, and it is filed twice because it is two obligations. Section 3 of the Canadian Human Rights Act enumerates marital status, so it is CRITICAL. US employment law does not, so it is LOW and stays a matter for documentation.

The regime has to be part of the title, and finding that out was not free. Without it the two findings share a title, the second regime to run deduplicates against the first, and the tool prints "already open" having filed nothing. A jurisdiction nobody had checked would inherit a severity set by one nobody had asked about.

check it yourselflive catalog

These are real entities in a running DataHub, not a rendering of them. Sign in with datahub / datahub. If the incidents were not really there, these pages would be empty.

they come back on their ownwithout being asked

Because the findings are real catalog entities rather than a report, a later lineage walk picks them up without being told they exist. The block below arrived inside an ordinary get_lineage response, which means an agent investigating something else entirely finds the earlier finding on the way past.

"health": [
  {
    "type": "INCIDENTS",
    "status": "FAIL",
    "message": "6 active incidents",
    "causes": ["ACTIVE_INCIDENTS"]
  }
]

The original plan was to file on the model, which is where the consequence lives. DataHub rejects an mlModel urn at the entity layer, so they are filed on the feature table instead. That turned out to be the better home rather than a consolation, since the feature table is where the column entered and where a fix is made.

step 06, what goes to the regulator

The night's work, in a form somebody can file

The last step of the loop turns one night's assessment into a dated document per jurisdiction. It is not a rendering of the policy pack. A pack says what a statute restricts, which is the same on every day of the year and tells a regulator nothing about whether this operator did anything. The document below is about one run: what the model was that day, what changed since the last assessment, what was measured, what moved, what did not, and where the record now lives.

generated for this run3 jurisdictions

Same model, same night, same engine. Only the named statute differs, and with it the severity, the citations and which duties apply. Each is a real PDF produced by tools/complydoc.py from the run you just walked through.

what is in itten sections
1 and 2The system assessed version, feature table, training run, the features it actually consumed, read from the registry rather than from documentation
3Method how the measurement works, and that the threshold is derived from observed variance rather than chosen
4Every measurement performed all 16, including the 15 that found nothingthe point
5Restricted attributes that could not be measured, and why never dropped silently
6 and 7The finding, its statutory basis, the column's provenance hop by hop, and the catalog references it was filed under
8Duties concerning the decision what this record supplies, and what it does not
9 and 10Scope, limitations, basis of preparation and a sign off block
section 4the silence is the evidence

Section 4 lists all sixteen measurements, not the one that fired. A record showing only what fired cannot be told apart from a record where the rest was never run, and a regulator asking whether an operator actually looked has no way to distinguish them. The fifteen quiet rows are what evidences that the examination happened.

Section 5 does the same job from the other side. Three attributes the statute restricts could not be measured at all, age because it is continuous and two more because the warehouse declares no categories for them. They are named with the reason, because an omitted attribute reads exactly like a clean one.

section 8duties are not a column list

Everything up to section 7 answers "may this attribute reach this model". Section 8 answers a different question: what does the operator owe the person the decision was made about. Quebec's Law 25 requires that they be told the decision was automated, given the principal factors, and offered a human to argue with. None of that is expressible as a list of forbidden columns, so it gets its own section, declared in the pack like everything else.

jurisdictiondutyrequireswe supplywe do not
canada Automated decision transparency, Law 25 s. 12.1 534
employment_us Records and adverse impact, 41 CFR 60-3 333
eu_ai_act Article 86 explanation, and Article 12 logs 554

The last column is the one that matters. Every duty has a limb this cannot reach: delivering the notice, explaining one individual decision, running the human review channel. A compliance document that quietly implied otherwise would be worse than one that says so, so each is listed by name under "NOT supplied by this assessment".

US employment law is worth reading for what is absent. There is no federal analogue to the Quebec duty: no right to be told an employment decision was made by a model, and no right to its principal factors. What exists instead is a records and adverse impact obligation on the employer, and that is what the US document carries.

honest about this pagewhat is not automated yet

The three documents above were generated by the tool from the recorded run, and the figures in them match the ones in steps 03 to 05 because both read the same history file. What is not yet wired is the nightly schedule that would produce them unattended and deliver them. Today it is a command, and the command is in the repository.

python tools/complydoc.py --model workforce-classifier --policy canada \
    --operator "Northwind Financial Group"
ariadne-workforce-classifier-canada.pdf  15,033 bytes  reference 930F3D073409