Janus for DataHub
Data-to-model reliability for DataHub

The missing CI for your ML supply chain

A model breaks because a column three joins upstream changed, and nothing connects the two. Janus writes that connection into DataHub, then reads across it: target leakage, stale upstreams, training-serving drift, and the three governance checks nothing else joins back to a model. Every finding arrives with the column chain that proves it and the smallest change that would clear it.

Run the quickstart Source on GitHub Read the benchmark
1.00 precision naming which feature leaks
Six checks, each with the column chain as proof
0.76 mutation score on the detectors, every survivor listed
Zero language model calls in any verdict
janus scan --table loans_raw
$ janus scan --table loans_raw

Stale upstream data in ecommerce.public.loans_raw  (severity: critical)
  stale for 30.0h against a 6.0h SLA
  blast radius: 1 dataset, 2 features, 1 model
    - Credit Risk v3 (critical) LIVE, 3 hops
  Any one of these clears this finding:
    - loans_raw refreshes inside its 6.0h SLA
    - Credit Risk v3 stops consuming loans_raw

Trust scores
  Credit Risk v3
    100  starting
    -40  upstream_failure: Stale upstream data in ecommerce.public.loans_raw
    -15  freshness_lag: Stale upstream data in ecommerce.public.loans_raw
    -10  missing_owner: nobody owns Credit Risk v3
    ---
     35  at-risk

Wrote back
  incident          urn:li:incident:f49230b1
  assertion         FAILURE, on the source table
  tagged models     1 of 1 at risk
  impact report     linked from the model
  process run       urn:li:dataProcessInstance:scan-cb2ef85ba9a8

run id: scan-cb2ef85ba9a8

What it does

DataHub holds two graphs that no other catalog holds together: column-level lineage across the warehouse, and ML metadata for the models. Nothing joins them, so a model is not connected to a single column and a data failure cannot be traced to the model it breaks.

Janus writes that join (janus link) and then reads across it. Every check below exists because of that one edge: a feature knows the exact source column it came from, so a stale table, a leaking label or a classified column can be walked forward into the live model that consumes it, with the column chain as evidence.

WAREHOUSE GRAPHloans_rawraw tablestg_customersstagingcustomer_features.contract_renewed_flaga column, not a tableML GRAPHtraining runmlflowcredit_risk_v3live modelmlFeaturewhat it trained onjanus linkthe edge no ingestion source writes
Lineage DataHub already holds The join Janus adds

Read right to left, that edge is what makes a verdict possible: from the live model back to the exact column, so a finding arrives as contract_renewed_flag <- churn rather than "something upstream of this model looks wrong".

1 READ THE GRAPH2 DECIDE3 WRITE BACKcolumn lineageschemastimestampstags and termssix checksdeterministic Pythonno model runs hereincidentmodel-at-risk tagtrust scoreguarding assertionimpact reportprocess runlanguage modelwording only
What a scan doesWhat a language model touches

Without an LLM key the wording comes from a template instead, and every verdict is identical.

Measured, not claimed

The same graph, the same ground truth, three ways of reading it, scored per feature: every approach can tell that a leaking model leaks, and the question that separates them is which of its features leaks, which is what somebody has to go and fix.

Januscolumn-level lineage 1.00
Table-level lineagethe usual catalog 0.25
Table quality checksno lineage at all 0.00

Precision at naming which feature leaks. Recall is 1.00, 1.00 and 0.00 respectively: the middle row does catch the leak, it just cannot say where.

ApproachPrecisionRecallStill alerting after the fix
Janus (column-level lineage)1.001.000 features
Table-level lineage0.251.002 features
Table quality checks, no lineage-0.000 features
ONE LABELLED TABLELOANS_LABELLEDdefault_flagthe label the model predictsregionTWO FEATURES OF ONE MODELapplicant_incomederives from the labelzip_codederives from regionTABLE LEVELsees loans_labelled, not its columnsflags applicant_incomeflags zip_codeone of the two is wrongCOLUMN LEVELsees default_flag → applicant_incomeflags applicant_incomeleaves zip_code alonenames the one somebody has to fix
A derivationA derivation from the label itself

And having never seen the column edge, table-level lineage cannot see it being removed either, so it keeps alerting on a graph somebody has already fixed. That is what gets a reliability tool switched off.

Scored on a graph this project did not build

Everything above runs on the graph janus-seed wrote, which is the graph where the links the detectors read already exist. So there is a second run that removes the seeder entirely: examples/real-project/ is a postgres warehouse holding a public dataset, a dbt project, a scikit-learn training script and an MLflow registry, ingested by DataHub's own postgres, dbt and mlflow sources. The leak is written into the dbt model rather than planted by a seeding call, and the column lineage the walk follows is what DataHub's SQL parser produced from the compiled query.

On that ingested graphResult
Features scored, one decision each7
Ground truth, read from customer_features.sql on diskcontract_renewed_flag
Flaggedcontract_renewed_flag, and nothing else
Precision, recall, false-positive rate1.00, 1.00, 0.00
Findings before anybody runs link0, and each check says what it was missing

Six of those seven features are clean, so a detector that answered "leak" to everything would score 0.14 here. The derivation it quotes (contract_renewed_flag <- churn) comes out of DataHub's own column-level lineage: no part of that path was written by this project. The section also states what it could not score on that stack, which is freshness, drift and the governance checks, because a real dbt project has no planted lag, schema change or classification in it.

Whether the benchmark could catch a broken detector at all

A precision table measures whether the detectors are right about a graph we chose. It says nothing about whether the trials would notice if a detector broke. That is a different question and it has its own answer: mutation testing, scoped to janus/detect/, because the claim under test is detection and mutating the rest of the package would measure something else.

Mutants generatedKilledSurvivedScore
195614904660.76

Every survivor is listed with a verdict, grouped by root cause: a real gap in the trials, or provably equivalent with the reason written out. A survivor with no verdict fails the report rather than being published silently, so the number cannot be improved by looking away. The two recurring classes are named once rather than thirty times: a continue mutated to break inside a loop the fixture only ever gives one item, and a finding's own URN swapped for None where a trial checks that a finding exists without checking what it says.

And whether the prose invents figures

Narrative quality stays unscored on purpose: a readability rubric is soft evidence that varies by provider. Narrative faithfulness is checkable and is checked. Every figure in generated prose must appear in the facts the narrator was shown, and every URN in it must resolve in the graph. A model that divides a 30-hour lag by a 6-hour SLA and writes "five times" has produced a figure nobody measured, and a reader cannot tell it from one that was.

Measured at 1.00 for the template narrator, which is what CI has a credential for. That rate is worth reading only beside the count of figures it actually checked, which RESULTS.md prints next to it: prose quoting no number at all is faithful by this measure and says nothing. A provider row appears only when a key for it was present, and its absence is stated rather than left to look like a pass.

Method, ground truth and what is still not measured: benchmarks/RESULTS.md.

Install

Linux, Python 3.11 exactly. Nothing else is required for a local run: the DataHub Quickstart ships with metadata service authentication disabled, and no LLM key is needed.

pip install janus-datahub          # the CLI and the Python API

pip install "janus-datahub[agent]" # scan --review, the approval agent
pip install "janus-datahub[mcp]"   # the janus-mcp server
pip install "janus-datahub[pet]"   # Argos, the desktop window
pip install "janus-datahub[feast]" # link --from feast
pip install "janus-datahub[kafka]" # watch --events
pip install "janus-datahub[otel]"  # metrics to an OTLP collector
pip install "janus-datahub[openai]"    # or [anthropic], or [google]
On the distribution name. The exact name janus was already taken on PyPI by an unrelated package, so the distribution is janus-datahub. The commands you run are still janus, janus-seed, janus-scenario and janus-mcp.

Developing on Janus itself, or before the first release is cut:

git clone https://github.com/Ahmedxsaad/janus
cd DataHub
pip install -e ".[dev]"
cp .env.example .env     # DATAHUB_GMS_URL=http://localhost:8080

Quickstart: watch a failure fire and clear

This builds the ML supply chain the DataHub datapacks lack, plants one silent failure, and detects it end to end. About two CPUs and 8 GB free.

datahub docker quickstart            # UI at http://localhost:9002 (datahub/datahub)

janus-seed                      # the ML supply chain: tables, columns, model, lineage
janus-scenario --lag-hours 30   # a source table silently stops refreshing
janus scan --table loans_raw    # detect, explain, write back

The scan names the live model at risk, then writes the incident, the model-at-risk tag, the risk properties, the guarding assertion and the impact report into DataHub. Run it twice: nothing duplicates.

Then recover and rescan:

janus scan --table loans_raw --dry-run   # detect and explain, write nothing
janus-scenario --revert                  # the table refreshes
janus scan --table loans_raw             # no finding, no writes
One caveat if you paste these as a block. DataHub indexes a freshness change asynchronously, about three seconds locally, so a scan run inside that window still reports the state from before. The scenario command says so when it returns. Give it a moment, or rerun the scan.

inventory: what can already be checked

Your DataHub is not the demo graph, so start by asking what Janus can see. Expect most models to come back "not checked", and that is the honest answer rather than a failure: out of the box a model is not connected to a single column, so a detector that walks from a feature to its source column has nowhere to start.

janus inventory        # every model, and what can and cannot be checked

It also prints how long findings have been staying open. Mean and median time to resolution, per finding type, read straight out of incidentInfo.created and the resolution stamp GMS wrote on Janus's own incidents. Nothing new is recorded to make that possible, and the median sits beside the mean because one incident left open across a weekend moves a mean by days and describes none of the others.

Including the model versions search hides from you

Registering a second version of an MLflow model produces a second mlModel entity and stamps the first one's isLatest false. GMS then drops every non-latest version out of search results, so the earlier entity is invisible to any query that finds models by searching, while remaining perfectly alive: not deleted, still carrying its aspects, still carrying whatever Janus wrote to it.

Every sweep here (inventory, scan --all-models, link --all) turns that hiding off, because two of its consequences are silent correctness failures rather than cosmetic ones: a model link --all cannot see is one that quietly stops being checked after the next ingest, and an incident raised on a version that later stops being latest can never be resolved, because nothing reaches the model to notice the finding is gone. Scanning every version costs less than it sounds, since the detectors only write for a model somebody linked, and an unlinked old version reports itself unchecked and writes nothing.

Before anybody links anything: the table-level answer

Until a model is linked, none of the column-level checks can run on it. Rather than only listing what it could not do, a scan says what it can see about the tables that model is recorded as training on: whether one is past its freshness SLA, marked deprecated by its owners, or holds a column your organization classified. It is a distinct finding type, it never outranks a column-level finding, it contributes nothing to the trust score, and it says out loud what it cannot see:

Checked at table level only (churn_model declares no features): the table this model trains on is past its freshness SLA. Which of the model's features carry the stale values is not knowable without a column-level link. Asked which feature carries it, table-level reasoning scores a measured precision of 0.25 (benchmarks/RESULTS.md, table-level baseline), which is why this finding names the table and not a feature. Run janus link to get the column-level answer instead.

That 0.25 is measured, not asserted: it is the table-level baseline in the benchmark above, and the benchmark checks the figure the tool quotes against the one it measures on every run, so the disclosure cannot drift from the measurement in silence. It is a tool upselling its own accurate story with its own numbers rather than with a claim.

The same sweep as one number

inventory answers per model. coverage folds that sweep into the figure a platform lead reports upward, names the single next declaration that would raise it most, and with --write records the point on Janus's own dataFlow so the next sweep has a direction to compare against. It measures how much has been declared, not how healthy the models are: a catalog at 8% is one where Janus mostly cannot tell you either way.

janus coverage --write

The ranking is keyed on the remedy rather than on the check, because one link unblocks three checks at once and a ranking by check would recommend the same action three times. Freshness is deliberately not in the figure: it is asked of a table, and folding one table check and five model checks into a single percentage divides two different denominators.

And the one report for a budget holder

finops lists the tables whose every downstream model has no deployment in service and has gone untouched past JANUS_UNUSED_MODEL_DAYS. One live consumer and a table is not listed, because that is not a saving. A model whose catalog entry carries no date at all is reported separately as undated and never as unused: in the one report here that suggests deleting something, an absence is not evidence. It writes nothing and raises no incident, because nothing here is broken.

janus finops

scan: detect, explain, write back

janus scan --table loans_raw       # start from a table, walk into the models
janus scan --model churn_model     # start from a model, walk back to the columns
janus scan --all-models            # the whole catalog
janus scan --model churn_model --dry-run   # detect and explain, write nothing
janus scan --model churn_model --review    # pause, show, write only what you approve

What a scan writes back, all of it idempotent and keyed so reruns never duplicate:

WriteWhere it lands
Incidenton the offending dataset, with the column chain as evidence and the changes that would clear it
model-at-risk tagon every downstream model in the blast radius
janus.trust_score, trust_bandstructured properties on the model
Guarding assertionon the source table, with its measured result
Model Impact Reporta document linked from the model
Process runthe scan itself, as a dataProcessInstance (below)

The trust score, led by what is wrong with it

An integer out of 100 invites a reader to compare it against a threshold nobody calibrated. So the number comes last, after the deductions that produced it, each naming the finding that caused it:

100  starting
-20  leakage: Target leakage in credit_risk_v3.applicant_income
-15  freshness_lag: Stale upstream data in ecommerce.public.loans_raw
-10  missing_owner: nobody owns Credit Risk v3
---
 55  watch

Printed under it, every time the number is shown: the weights behind this score are a stated preference ordering, not a calibrated model. Compare scores to each other, not to a threshold. Every history entry carries a SCORING_VERSION, so a change to the weights reads as a labelled discontinuity in the trend rather than as a mystery drop, and a test fails if the weights move without the version moving with them.

The input contract, as a standard rather than as ours

An incident says what went wrong. A contract says what was expected to be true in the first place. --contract-out renders every table the model trains on as an Open Data Contract Standard file (ODCS v3.1.0, Linux Foundation Bitol): the schema each input should have and how fresh it should be, quoted from the graph's own schemaMetadata rather than invented. Anything that speaks ODCS can enforce it, not only Janus.

janus scan --model credit_risk_v3 --contract-out ./credit_risk_v3.odcs.yaml

The six checks, and what each one needs

A scan never reports something healthy that it could not measure. It names the check, the missing metadata, and how to supply it.

Freshness and blast radius

A source table stopped refreshing, and live models are still scoring on features derived from it.

Needs the operation aspect, written by dbt, Airflow or Spark.

Target leakage

A feature descends from the label the model predicts, so its offline score is inflated by construction.

Needs features with source columns, from janus link.

Schema drift

The input schema moved since training: a column added, removed or retyped under a model nobody retrained.

Needs a training-time snapshot, from janus link.

Sensitive source

A feature derives from a column somebody classified PII, PHI or restricted. Nothing is broken; what the model was allowed to see is.

Needs JANUS_SENSITIVE_TAG_URNS, your own taxonomy.

Deprecated input

A table's owners marked it deprecated. They have no way to know a live model still depends on it.

Needs nothing: deprecation is DataHub's own aspect.

Proxy candidate

A feature and a column classified as a protected attribute descend from a common ancestor. A question for a human, never a verdict.

Needs JANUS_PROTECTED_ATTRIBUTE_TAG_URNS, your own taxonomy.

The three that read the governance graph

The first three checks ask whether a model's data is correct. The last three ask something the organization has already answered elsewhere in DataHub, and that nothing today joins back to the model.

Sensitive source. Somebody classified a column as PII, PHI or restricted. Three joins downstream, a feature derives from it and a live model trains on that feature. Nothing is broken; what is wrong is what the model was allowed to see. It is the leakage walk with a different mark, so it produces the same auditable proof:

credit_risk_v3 feature applicant_income derives, through applicant_income <- income, from loans_raw.income, classified janus.sensitive.
JANUS_SENSITIVE_TAG_URNS=urn:li:tag:PII,urn:li:tag:Confidential
JANUS_SENSITIVE_TERM_URNS=urn:li:glossaryTerm:Classification.Restricted

There is deliberately no default. A guessed classification URN either matches nothing or matches a term that means something else in your catalog, and a false incident about a compliance exposure is the worst kind to be wrong about. Leave both empty and every scan reports the check as not evaluated, never as clean.

Deprecated input. A table's owners marked it deprecated, with a note and sometimes a decommission date. They have no way to know a model depends on it. This needs no configuration: deprecation is DataHub's own aspect with one meaning everywhere. It is never more than medium severity, because it is a deadline rather than a defect.

Proxy candidate. A feature and a column somebody classified as a protected attribute both descend from a common ancestor, with neither descending from the other. That shape is how a proxy usually arises, and it is also how two harmless siblings arise, so the finding is raised as a question for a human and capped at medium forever: it never escalates on a live model, it contributes nothing to the trust score, and its first suggested remedy is to review it, which no machine here will perform for you. Whether a feature actually proxies for a protected attribute is a statistical claim about data this tool has never read a row of. What it can say, structurally, is where to look, and it can say it before the model is trained. Configured, like the classification above, from your own taxonomy with no default:

JANUS_PROTECTED_ATTRIBUTE_TAG_URNS=urn:li:tag:ProtectedAttribute
JANUS_PROTECTED_ATTRIBUTE_TERM_URNS=urn:li:glossaryTerm:Protected.Ethnicity

A direct descent is P5's finding and is excluded here rather than reported twice: a proved derivation is not a candidate. The first two checks are reversible scenarios, so you can watch them fire and clear:

janus-scenario --scenario sensitive-source
janus scan --model credit_risk_v3
janus-scenario --scenario sensitive-source --revert

The fix, not just the fault

Every finding carries a counterfactual: a set of changes, each one sufficient on its own to clear it. Not advice, and not a language model's suggestion. It is derived from the same traversal that found the problem, which already computed every derivation path and previously threw all but the shortest away.

Target leakage in credit_risk_v3.applicant_income  (severity: critical)
  feature      applicant_income
  leak path    applicant_income <- income_band <- default_flag
  label        loans_labelled.default_flag
  model        Credit Risk v3 LIVE

  Any one of these clears this finding:
    - Rebuild applicant_income from data known before the outcome is, so it
      stops deriving from the label: cut applicant_income <- income_band,
      applicant_income <- default_flag_backfill.
    - Drop the feature applicant_income from Credit Risk v3 and retrain, so
      nothing the model reads carries the answer.
    - If loans_labelled.default_flag is not this model's label, remove the
      label declaration from it: the finding rests on that declaration and is
      wrong without it.

  This finding rests on 2 distinct derivation paths. Cutting one of them
  leaves it standing: the cut listed above names the first edge of every
  path, and all of them have to go.

That last clause is the moat stated inside an incident somebody actually reads. A table-level tool cannot express it, because it never saw the column edge in the first place. Each detector has its own set: a freshness finding clears when the table refreshes inside its SLA or the model stops consuming it, a drift finding when the column returns to its training-time type or the model is retrained, a sensitive-source finding when the derivation is cut or the classification is corrected (worded as a correction and never as a dismissal, naming the owner of the classification), and a deprecated-input finding when the model moves to the successor the deprecation note names. If there is no note, it says to go and ask rather than inventing a table name.

Each one was applied, and the detector asked again

A suggested fix nobody performed is not a measurement. So the benchmark performs them: it applies each counterfactual to the live graph and reruns the same detector.

DetectorRemedy appliedCleared itNot mechanically applicable
Upstream freshnessrefresh-sourceyesstop-consuming
Target leakagecut-lineageyesdrop-feature, correct-mark
Input schema driftrestore-schemayesretrain
Sensitive sourcecorrect-markyescut-lineage, drop-feature
Deprecated inputwithdraw-deprecationyesmigrate-input
Table-level riskdeclare-linkyesmigrate-input
Proxy candidatecut-lineageyesreview, drop-feature

The last column is not a gap being hidden. Retraining a model, migrating onto a successor table and dropping a feature are real fixes that no metadata write can carry out, so they are named as unverified rather than counted as passes.

default_flagthe labelincome_banddefault_flag_backfillapplicant_incomethe featuretwo derivations, one featureCUT THE PATH THE INCIDENT QUOTESincome_band cutbackfill still therethe finding still firesCUT EVERY FIRST EDGEincome_band cutbackfill cutthe finding clears

The benchmark plants exactly this graph and cuts the quoted path, because without the second control a detector that could not be silenced at all would score the same as one that works.

This is the counterfactual explanation of the xAI literature (Wachter, Mittelstadt and Russell, 2017) applied to a lineage graph rather than to a feature vector. It is fully deterministic and needs no model, no rows and no LLM: the same walk, asked what would have to be different.

gate: block a bad model before it merges

Everything above audits a graph that already holds the mistake. janus gate is the preventive half, for a pull request. It runs the same detectors, judges them against a policy, and answers in an exit code.

janus gate --model credit_risk_v3 --block-at-or-above high   # exit 1 if it leaks
janus gate --model credit_risk_v3 --min-trust 80             # exit 1 if trust < 80
Exit codeMeaning
0shippable
1the policy was violated
2the gate could not reach a verdict (DataHub unreachable, bad config)

The third one is the point. A gate that reported "I could not connect" as a policy violation would teach a team to wave through every red build, so a setup failure never masquerades as a finding. The gate writes nothing by default, because it runs on every push and one incident per run would fill the graph with findings about branches that never merged.

--min-trust on its own prints a warning, because it is the blunt secondary control: a score is a preference ordering and a threshold over it is not a policy. Pair it with --block-at-or-above, which judges the findings themselves.

In a workflow

- uses: Ahmedxsaad/janus@main
  with:
    model: credit_risk_v3
    block-at-or-above: high
    gms-url: ${{ secrets.DATAHUB_GMS_URL }}
    gms-token: ${{ secrets.DATAHUB_GMS_TOKEN }}

The verdict lands on the run's own summary page, not just in the log: findings, severities, trust scores and the checks that could not run, as a table the reviewer sees without opening anything. That needs no input and no token, because GitHub already gives every step a GITHUB_STEP_SUMMARY file to append markdown to; outside Actions the variable is unset and nothing is written.

watch: keep looking

watch is the long-running entry point: it polls, scans on change, and auto-approves its writes because it is unattended by definition. It is what the janus-watch Helm chart runs, alongside an optional link --all CronJob for the models that are not retrained nightly.

janus watch --table loans_raw           # poll one table
janus watch --table loans_raw --pet     # and put Argos on the desktop

Its own numbers, where your other numbers are

Every completed scan already logs what an SLO is built from: findings raised, writes made, and how long detection itself took, separate from the poll interval and from DataHub's own indexing, because watch controls neither. Set JANUS_OTEL_ENDPOINT and the same three numbers go to an OTLP collector as metrics. Unset, and nothing is imported and nothing is exported.

Three instruments and no traces, on purpose. A team that wants spans across the DataHub SDK's HTTP calls installs opentelemetry-instrumentation-requests and gets them, which is better than this project shipping a second, worse copy of it.

pip install "janus-datahub[otel]"
JANUS_OTEL_ENDPOINT=http://localhost:4318/v1/metrics janus watch --table loans_raw

React to the graph instead of a timer

watch --events consumes DataHub's own MetadataChangeLog instead of polling, and does one thing polling structurally cannot: it re-applies, catalog-wide, any janus link an ingestion run drops.

janus linkthe model declaresits featuresdatahub ingestmlModelPropertiesupserted wholemlFeatures gonethree checks reportnot evaluatedwatch --eventsreads the change log,re-applies the linknothing errors, and nothing in the catalog looks wronga model that was fully checked yesterday is simply no longer checked

It replays only what a human already confirmed, so a model nobody linked is left alone: an inferred join looks identical to a confirmed one in the graph and would make every detector downstream confident about the wrong columns. Polling remains the default and needs no broker.

pip install "janus-datahub[kafka]"
janus watch --events --model credit_risk_v3

Call it from your training script

The command line is the main interface, but there is one place Janus belongs inside your code: the script that trains the model. That is the only moment when the feature table, the label column and the training-time schema are all known.

import mlflow

from janus import link_model, scan_model

FEATURE_TABLE = "analytics.customer_features"

# Logged as a run parameter as well as declared: the parameter survives into
# DataHub through the ordinary mlflow ingest, which is what lets `link --infer`
# read the table next time instead of guessing at it.
mlflow.log_param("janus_features", FEATURE_TABLE)

link_model(
    model="churn_model",
    features=FEATURE_TABLE,
    label_column="churned",
    exclude=["customer_id"],
)

report = scan_model(model="churn_model", dry_run=True)
if not report.clean:
    raise SystemExit(f"{len(report.writes)} finding(s) before this model ships")

Declared here, the link is re-declared by the same run that produces the model, so the next training run repairs whatever the last ingest dropped.

Two functions and their result types, and deliberately no more: those names are the supported surface a script may pin to. They are thin wrappers over exactly the functions janus link and janus scan call, so a finding found here is found identically at the command line. Both read .env the same way the CLI does; pass conn= to reuse one connection across many models. Everything else in the package is importable and documented, but its shape is free to change.

JSON output

Routing findings somewhere Janus does not know about? Both scan and gate take --format json and put the whole report (evidence, models at risk, trust deductions, each finding's counterfactual, the gate's violations) on stdout as one parseable document, with progress lines moved to stderr so the stream stays clean. The counterfactual goes out as data rather than as prose: a stable remedy kind and its targets, so a consumer can act on it without parsing an English sentence.

janus scan --model credit_risk_v3 --format json | jq '.findings[].severity'
janus scan --model credit_risk_v3 --format json | jq '.findings[].counterfactual.remedies[].kind'

Ask it, do not type it

pip install "janus-datahub[mcp]"
janus-mcp   # serves check_leakage, check_freshness, check_gate over stdio

Point an MCP client at the installed janus-mcp command and ask "is credit_risk_v3 leaking?" in plain language. All three tools are read-only, enforced at registration (readOnlyHint: true) and by calling every scan in dry-run with no way to turn that off: the model on the other end of an MCP client is not Janus's own narrator, it is outside this project's control entirely, so it gets to ask what is wrong and nothing more.

It is meant to run beside DataHub's own mcp-server-datahub, not instead of it. That server answers what the catalog contains: search, lineage, schemas, ownership, the open-ended questions where a model's job is to explore. Janus answers the three that have to be reproducible, with the column chain as evidence and no LLM anywhere in the decision.

Argos, the pet on your desktop

pip install "janus-datahub[pet]"      # macOS and Windows; Linux: the .deb or
                                           # .AppImage on the GitHub release
janus watch --table loans_raw --pet   # Janus's own findings
janus companion                       # everything wrong with the assets you own

Argos is a 32x32 pixel watchdog that sits on your desktop and shows what the graph is doing. He patrols while a poll finds nothing, sniffs while a lineage walk is in flight, barks with a red collar the moment a finding lands, and turns into a translucent ghost when he cannot reach DataHub, because a cheerful pet on a disconnected watch is the lie that gets ambient status displays switched off. Nothing he does is on a timer: every state is an event a detector actually produced.

Double-click a finding and he walks the blast radius across the screen, one hop per graph hop, with the column name floating over each jump. That is the column-level traversal the benchmark above measures, rendered as motion instead of a paragraph.

janus companion is the half that is not about Janus at all: it runs no detector, and sweeps the assets one owner owns for open incidents, failing assertion runs and deprecations. DataHub has no desktop presence today, and that is the gap it fills.

With no window binary installed, both commands report one line per change in the terminal instead, which is also what runs over SSH. Design and protocol: docs/plan/08-watchdog-mascot.md.

The dog walking this page is the dog in the window. Every frame down there is read from argos/ui/sprites/argos.txt, the same file the Tauri window, the app icon and the README animation read. A hand-made copy of the art would go stale the first time somebody redrew a leg.

Run it without a Python install

datahub docker quickstart              # once: builds DataHub's own stack
docker compose run --rm janus-seed
docker compose run --rm janus scan --table loans_raw
docker compose run --rm janus gate --model credit_risk_v3 --block-at-or-above high
docker compose up janus-mcp       # long-running, stdio

The compose file adds Janus to the Docker network datahub docker quickstart already creates, rather than reimplementing DataHub's own multi-container stack inside this repository.

Every flag, in one table

The sections above show each command doing the thing it exists for. This is the whole of the rest, so no option is reachable only by typing --help. Every command also takes --help, and none of them take a positional argument.

scan

FlagWhat it does
--tableA dataset to audit: a full URN, or a name such as loans_raw
--modelA model to audit: a full URN, or a name such as credit_risk_v3
--all-modelsEvery model in the graph, one after another
--dry-runDetect and explain, write nothing
--reviewShow each finding and prompt before writing it
--auto-approveRun the same approval agent but write without prompting
--sla-hoursFreshness SLA for this run, overriding JANUS_FRESHNESS_SLA_HOURS
--formattext for a human, json for a program
--report-outAlso write the impact report markdown to a path on disk
--assertion-outAlso write the guarding assertion as YAML to a path on disk
--contract-outWrite the ODCS input contract there. Needs --model
--no-llmSkip the narrator and use the deterministic template prose
--llm-provider, --llm-modelOverride the provider and model id for this run only

The two -out flags are for the reader who has no DataHub login: the same report and the same assertion the scan writes into the graph, as files to attach to a ticket or commit beside the model. They are additional, never instead of the write.

gate

FlagWhat it does
--table, --modelWhat to judge, same resolution as scan
--block-at-or-aboveExit 1 on any finding this severe or worse: critical, high, medium, low
--min-trustExit 1 below this score (0-100). The blunt secondary control
--writeWrite findings back too. Off by default: a gate runs on every push
--sla-hoursAs above
--formatjson also suppresses the GitHub annotations, which are not JSON
--llmNarrate the violations. A gate defaults to no LLM: it needs a verdict, not prose, and only with this flag do --llm-provider and --llm-model mean anything

link

FlagWhat it does
--modelThe trained model. Omit only with --all
--featuresThe table it trains on. Omit to reuse what a previous link recorded
--label-columnThe column it predicts. Omit to reuse what a previous link recorded
--label-tableWhere that column lives. Defaults to the feature table
--excludeA feature-table column that is not a feature, such as a join key. Repeatable
--inferWork the arguments out from the graph and show them for confirmation
--yesAccept an inferred proposal without prompting
--from, --repo, --selectRead the declaration out of a Feast or dbt repo instead of typing it
--allReplay the recorded link for every model that has one
--dry-runShow what would be declared, write nothing

watch

FlagWhat it does
--table, --modelWhat to keep looking at
--intervalSeconds between polls
--oncePoll a single time and exit. For scripts and for a recorded demo
--eventsReact to the change log instead of polling, and re-apply a dropped link catalog-wide
--petPut Argos on the desktop and let this watch drive him
--sla-hours, --no-llm, --llm-provider, --llm-modelAs on scan

companion

FlagWhat it does
--ownerThe owner URN whose assets to sweep. Defaults to JANUS_COMPANION_OWNER
--intervalSeconds between sweeps
--onceSweep a single time and exit
--no-windowReport in the terminal rather than on the desktop. What runs over SSH

The read-only reports

CommandFlags
janus inventory--limit, to stop after this many models
janus coverage--limit, and --write to record the point in the trend
janus finops--limit, and --days, the idle window before a model counts as unused
janus crosswalkNone. It prints the table generated from the detector registry
janus model-card--model, and --write to publish it against the model
janus evidence-pack--model, --write
janus feature-card--model, --write, and --feature to filter within it

--limit is there because a first run against somebody else's catalog should be cheap to try. It is a stop, not a sample: the models are taken in the order the graph returns them, so a limited sweep answers honestly about the models it looked at and says nothing about the rest.

The other three commands

janus-seed         # build the demo ML supply chain
janus-scenario     # plant a failure: --lag-hours, --scenario, --revert
janus-mcp          # the read-only MCP server, over stdio

Configuration

Configuration enters the process in exactly one module, janus/env.py, and comes from .env. Copy .env.example and fill it in; the two files carry an identical key set in the same order, so copying one produces a working run.

DATAHUB_GMS_URL=http://localhost:8080
DATAHUB_GMS_TOKEN=                      # not needed for a local Quickstart

JANUS_LLM_PROVIDER=                # anthropic, openai or google. Optional
JANUS_LLM_MODEL=
JANUS_LLM_API_KEY=

JANUS_SENSITIVE_TAG_URNS=          # your own classification taxonomy
JANUS_SENSITIVE_TERM_URNS=
JANUS_PROTECTED_ATTRIBUTE_TAG_URNS=    # and your own protected attributes
JANUS_PROTECTED_ATTRIBUTE_TERM_URNS=
JANUS_LABEL_TERM_URN=              # honor your existing label term
JANUS_OTEL_ENDPOINT=               # OTLP metrics, off unless set

Three rules govern that file, and they are worth knowing before you debug a run:

  • Anything that identifies a system, an account or a vendor has no default and no fallback. Server URLs, tokens, API keys, provider names, model ids. A fallback is a machine-specific value in tracked code: it turns a missing .env into a silent connection to the wrong place, or a silent call to the wrong vendor billed to whatever key is in the ambient environment. Missing means missing, and it fails loudly, naming the variable.
  • A group of related settings is all-or-nothing. Set every one or none. A half-configured feature fails loudly, it never downgrades in silence.
  • Algorithm parameters are not identity. Thresholds, hop caps and score weights keep documented defaults in janus/config.py, and you can override them.

Already have a glossary term for labels? Point JANUS_LABEL_TERM_URN at it and the detector honors yours instead of creating one.

The parameters, with the defaults they ship with

These are the third rule above: they change how hard the detectors look, not what they connect to, so each carries a documented default and a run with an empty .env uses it.

JANUS_FRESHNESS_SLA_HOURS=6      # past this, a table is stale
JANUS_MAX_HOPS=3                 # how far a blast-radius walk goes downstream
JANUS_LEAKAGE_MAX_HOPS=6         # a leak path may be longer than a blast radius
JANUS_PROXY_MAX_HOPS=3           # how far up to look for a common ancestor
JANUS_LINEAGE_RESULT_CAP=500     # edges read per hop, so one hub table cannot stall a scan
JANUS_LABEL_COLUMN_NAMES=        # the names --infer will propose as a label
JANUS_UNUSED_MODEL_DAYS=90       # idle window before finops calls a model unused
JANUS_COMPANION_ENTITY_CAP=200   # assets one companion sweep reads

The two hop caps are separate on purpose. A blast radius answers "who is affected", and three hops of that is already a wide answer. A leak path answers "where did this value come from", and a real warehouse puts six joins between a label and the feature that quietly carries it, so the same cap on both would either miss leaks or make every scan expensive.

And the operational half

JANUS_LOG_FORMAT=                # json, for a log pipeline. Unset: human-readable lines
JANUS_OTEL_HEADERS=              # for a collector behind an auth header

JANUS_KAFKA_BOOTSTRAP=           # the three watch --events needs, all or none
JANUS_SCHEMA_REGISTRY_URL=
JANUS_KAFKA_GROUP_ID=

JANUS_ARGOS_BIN=                 # the desktop window, if it is not on PATH
JANUS_DATAHUB_UI_URL=            # the UI port, not GMS: what Argos opens on a click
JANUS_COMPANION_OWNER=           # whose assets janus companion sweeps

JANUS_LOG_FORMAT=json is for the one process that runs for days. A human tailing watch wants scan complete run_id=... findings=1; Loki or an ELK stack wants fields it can index without a per-tool regular expression that breaks the first time a key is added. Both come from the same call site, so a field cannot exist in one and be missing from the other.

JANUS_DATAHUB_UI_URL is a different port from DATAHUB_GMS_URL above (9002 rather than 8080 on a Quickstart), which is why it is asked for rather than derived. Without it Argos says so on the finding instead of opening a browser at a guess. The Kafka three are a group, so setting one of them and not the others fails loudly rather than quietly falling back to polling.

Security model

  • The LLM never decides anything. Detection is deterministic Python. Catalog text (descriptions, column names, glossary definitions) is attacker-controllable, so it is wrapped as delimited untrusted data and delimiter lookalikes are stripped before wrapping. Even a successful injection cannot invent a finding: it is downstream of the detectors.
  • No row of your data ever leaves DataHub. Every check above is computed from metadata: lineage edges, schemas, timestamps, tags and terms. There is no warehouse connection to configure, so there is none to leak, and the narrator is shown facts rather than samples.
  • Writes are fixed and parameterized. A closed set of mutations with validated arguments. There is no code path that sends a GraphQL string the caller supplied.
  • Writes are idempotent and reversible in place. Keyed on (resource_urn, incident_type, title) with read-before-write, stamped with a janus.run_id for provenance. The benchmark reads the graph back after a rerun and measures the duplicates created: 0.
  • Writes are gated on a human. scan --review writes only what you approve. gate does not write unless you pass --write. The MCP tools cannot write at all, on any flag. watch auto-approves because it is unattended by definition.
  • The token stays a secret. It enters the process in one module, lives only in a git-ignored .env, and is never logged, echoed or put in an exception message. Errors name the variable, never its value.
  • Least privilege, honestly. DataHub OSS personal access tokens are not scoped per operation, so Janus cannot claim a narrowed token. What it can say is what it touches: incidents, tags, glossary terms, structured properties, documents and assertion aspects. Give it a token you are willing to rotate, and rotate it.

Each detector implements a published result rather than a heuristic somebody liked:

WhatSource
Target leakage as illegitimate information about the target, found by inspecting how a feature was constructedKaufman, Rosset and Perlich, Leakage in Data Mining (KDD 2011 / ACM TKDD 2012)
Undeclared consumers: a table acquiring model consumers its owners never agreed to serveSculley et al., Hidden Technical Debt in Machine Learning Systems (NeurIPS 2015)
A schema fixed at training time, against which serving data is continuously validatedBreck, Polyzotis, Roy, Whang and Zinkevich, Data Validation for Machine Learning (MLSys 2019)
Proxy variables as the dominant mechanism of unintentional discriminationBarocas and Selbst, Big Data's Disparate Impact (California Law Review, 2016)
The smallest change that would have produced a different outcome, as the explanationWachter, Mittelstadt and Russell, Counterfactual Explanations Without Opening the Black Box (2017)
The prompt-injection and sensitive-disclosure threat modelOWASP Top 10 for LLM Applications (2025), LLM01 and LLM06

Janus inside the graph it guards

The product's thesis is that a pipeline nobody catalogued is a pipeline nobody can reason about. An agent that writes incidents into a graph while remaining invisible in that same graph is exempting itself from its own argument. So every scan is emitted as what it is: a process run.

dataFlowthe Janus agentdataJobthe scandataProcessInstanceone per run, keyed by run_idinputsthe entities it readoutputsthe entities it wroterun eventstarted, then completeor failed, which is not silence

No new dependency, no scrape endpoint and no second system to run: those are DataHub's own shipped entities. What it buys is that an incident stamped Raised by Janus run scan-cb2ef85ba9a8 stops sending a reader to grep a log file. The run is an entity they can open, whose inputs say what was examined and whose outputs say what was touched.

A scan that dies emits a failed run rather than silence. Detection runs inside the run too, so a scan that dies deciding what is wrong is recorded the same way as one that dies writing it down. That is the difference between "clean" and "never completed", and without it a half-written graph says nothing at all. A --dry-run emits nothing, and a rerun with the same run_id converges rather than stacking, like every other write here.

One honest limit. The input and output aspects accept dataset and mlModel only, which is the relationship annotation in DataHub's own model: a live GMS answers 422 for anything else. So a column is reported as its parent dataset, and the incidents, assertions and documents a run writes stay reachable from the asset they hang off rather than appearing in the aspect. Each still carries the run_id.

Where this fits a governance framework

This is a mapping, not a conformity claim. Each row says which NIST AI RMF subcategory a detector's output is evidence for; whether that subcategory is satisfied is a judgement about your organization's whole process, which no tool reading a metadata graph can make. Subcategory text is quoted from the NIST AI RMF 1.0 Playbook.

Generated from the detector registry rather than typed by hand, so a check cannot be added to Janus without appearing here. Run janus crosswalk to print this table as markdown, straight into whatever document you have to file.

DetectorEvidence it writes into the graphMAPMEASUREMANAGE
Freshness and blast radiusA freshness incident on the failing table, a guarding assertion with its measured run, and the named live models downstream of itMAP 4.1MEASURE 2.4MANAGE 4.1
Target leakageA field incident on the offending column, quoting the derivation chain from the feature to the label column, hop by hopMAP 2.3MEASURE 2.5MANAGE 2.3
Input schema driftA schema incident naming every column added, removed or retyped since the training-time snapshot recorded on the training runMAP 2.3MEASURE 2.3MANAGE 4.1
Sensitive sourceA field incident naming the classification the organization applied and the path from the classified column to the feature that carries itMAP 4.1MEASURE 2.10MANAGE 2.3
Proxy candidate (for human review)A field incident naming the column a feature and a classified protected attribute both descend from, raised as a question for a human rather than as a determination that the feature proxies for the attributeMAP 2.3MEASURE 2.11MANAGE 2.3
Deprecated inputAn incident on the deprecated dataset, quoting its owners' own note and decommission date, against the models still training on itMAP 4.2MEASURE 2.4MANAGE 2.2
Table-level risk (degraded mode, no column link)An incident on the training table naming the table-level fact, the mode that produced it, and that mode's measured precision, so the record states how far it can be trustedMAP 4.1MEASURE 2.5MANAGE 4.1

The subcategories cited above, quoted from the NIST AI RMF 1.0 Playbook:

  • MAP 2.3 Scientific integrity and TEVV considerations are identified and documented, including those related to experimental design, data collection and selection.
  • MAP 4.1 Approaches for mapping AI technology and legal risks of its components, including the use of third-party data or software, are in place, followed, and documented.
  • MAP 4.2 Internal risk controls for components of the AI system including third-party AI technologies are identified and documented.
  • MEASURE 2.3 AI system performance or assurance criteria are measured qualitatively or quantitatively and demonstrated for conditions similar to deployment setting(s).
  • MEASURE 2.4 The functionality and behavior of the AI system and its components are monitored when in production.
  • MEASURE 2.5 The AI system to be deployed is demonstrated to be valid and reliable. Limitations of the generalizability beyond the conditions under which the technology was developed are documented.
  • MEASURE 2.10 Privacy risk of the AI system is examined and documented.
  • MEASURE 2.11 Fairness and bias, as identified in the MAP function, are evaluated and results are documented.
  • MANAGE 2.2 Mechanisms are in place and applied to sustain the value of deployed AI systems.
  • MANAGE 2.3 Procedures are followed to respond to and recover from a previously unknown risk when it is identified.
  • MANAGE 4.1 Post-deployment AI system monitoring plans are implemented, including mechanisms for capturing and evaluating input from users and other relevant AI actors, appeal and override, decommissioning, incident response, recovery, and change management.

Three documents, generated instead of maintained

janus model-card    --model credit_risk_v3   # prints; --write publishes it to DataHub
janus evidence-pack --model credit_risk_v3   # EU AI Act Article 10
janus feature-card  --model credit_risk_v3   # one Data Card per feature

A model card in the sense Mitchell et al. (FAT* 2019) proposed one: intended use, which columns each feature was actually computed from, the reported training metrics, the trust score with its waterfall, the findings open against the model, and the checks that could not run. Intended use is the one section a graph cannot derive, so an undeclared one says exactly that rather than being omitted. An evidence pack mapping the same graph to Regulation (EU) 2024/1689 Article 10 and Article 12, by paragraph number (10(2)(b), 10(3), 10(2)(f), 10(5)), so a reader can check the mapping rather than trust it.

Neither is maintained by hand, which is the whole point: a hand-written model card is accurate until the model next changes, and most of them are already wrong. These are regenerated from the graph, so they are current by construction and empty where the catalog is. Anything DataHub does not record prints as not recorded in the catalog rather than being quietly dropped, so a gap is visible in the document instead of reading as an absence of a problem.

feature-card is the third, a Data Card in the sense Pushkarna, Zaldivar and Kjartansson (FAccT 2022) proposed one, but for a single feature: where it is computed from hop by hop, every other derivation the walk found, each table that chain crosses and how current it is, whether the chain reaches a column classified as restricted or as a protected attribute, whether its type has moved since training, and, when a finding names it, the changes that would clear it. Taken per model because that is what somebody has in hand; --feature filters within it. Its freshness figures say out loud that they are measured now and not at training time, for the same reason the evidence pack refuses to substitute one for the other.

The evidence pack's first heading is This is not a compliance certification, and its second is What this pack could NOT establish: deliberately the first section rather than a closing caveat, because a gap at the end of a long document is a gap nobody reads. It states, for instance, that freshness at training time is unknowable from this graph (Janus measures freshness now, which is a different claim and not a substitute), and that whether anyone examined the data for bias is an activity no catalog records. A generated document that implied conformity would be worse than no document at all.

All three print by default and write nothing. --write publishes the document against the model, keyed on the model alone, so regenerating replaces it rather than leaving a second copy behind. Either way the underlying scan is read-only: asking for documentation never raises an incident as a side effect.