examples/. Nothing here is generated on page load. The DataHub links are live, so the findings can be checked against the catalog itself.
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.
The instance this recording was made against. Real Postgres, real dbt project, real MLflow registry, 378,817 US Census records for California 2018.
One address and one access token, read scoped. The catalog already knows which dataset feeds which model, so it is never asked for separately.
A read-only connection, or a sampled extract of the feature table. Needed only for the rebuild, because no catalog anywhere holds rows.
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
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.
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.
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.
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
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.
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
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.
| regime | watches | verdict on public_coverage_flag |
|---|---|---|
employment_us | columns tagged protected_attribute | nothing to report |
ecoa | protected bases under fair lending | nothing to report |
canada | the section 3 grounds, by tag | nothing to report |
eu_ai_act | protected attributes and personal data | examine, 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.
Nothing about the warehouse differs between these rows. Same graph, same tags, same engine. Only the named statute changes.
| attribute | canada | ecoa | employment_us | eu_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.
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.
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.
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.
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.
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.
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.
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.
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.
| jurisdiction | duty | requires | we supply | we do not |
|---|---|---|---|---|
canada |
Automated decision transparency, Law 25 s. 12.1 | 5 | 3 | 4 |
employment_us |
Records and adverse impact, 41 CFR 60-3 | 3 | 3 | 3 |
eu_ai_act |
Article 86 explanation, and Article 12 logs | 5 | 5 | 4 |
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.
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