Verification control for DataHub agents
DataHub says the column exists.
Sidq cross-checks DataHub against its own graph, live PostgreSQL, and the transformation SQL behind stored lineage before an agent acts. A deterministic policy returns PASS, WARN, or BLOCK, proves any repair, and writes re-verifiable evidence back to DataHub.
No LLM decides. Missing evidence never becomes a pass.
- Live PostgreSQL
- 285 contradictions in DataHub’s own showcase catalog
- Docs + constraints vs PostgreSQL
- 1,180 tests · 84.20% branch
- Official DataHub MCP
Runs a real command on this host and prints the real output. 5 proof runs per client every 10 minutes; a refusal says which limit applied and when it resets.
- Compare
- DataHub ↔ DataHub · live PostgreSQL · transformation SQL
- Decide
- one deterministic policy → PASS · WARN · BLOCK
- Prove
- simulate the repair, then re-run every check against the catalog it would create
- Remember
- native Assertion + Receipt → a separate reader verifies it again
Recorded proof — not live. Output of make gate-demo, captured at git revision f006136. Offline verdict below re-runs this exact command live on this host; the buttons beside it run different proofs.
examples/01 and every artifact quoting its hash are current DECISION : BLOCK RULES : wide_blast_radius, critical_downstream COMMIT : 5addb753788935d4d1aa6a9483c28c6fc124e5c7 POLICY : 66f48004804c5ce02955699710466b6d58ae7a868f876a4774e548c5c15920b8 Reproduced from examples/01-blocked-pii-dashboard/ — same policy, same commit, byte-identical verdict. Full evidence: examples/01-blocked-pii-dashboard/verdict.json
Five fixed read-only commands run on this host. Output prints here.
Five runnable demonstrations, each a fixed read-only command from a closed table. They are what this host will execute for you — not the whole of what the page proves. Claims turns DataHub prose into bounded SELECT-only SQL against PostgreSQL; audit hunts catalog contradictions; repair proves proposed fixes without applying them; handoff re-verifies persisted state; gate-demo re-derives the committed BLOCK offline. The times beside them are measured on this host, not estimated.
Three read-only MCP tools
No hidden mutation. Their verification store is not a DataHub Receipt reader — the independent Receipt read is the proof above.
check_change · verify_context · search_verified
Use Sidq in an agent or CI
One engine and one policy hash behind every surface: an MCP server agents call directly, a DataHub Skill that fires on “can I trust this asset”, the CLI, a GitHub Action, and a PR bot that posts a single provenance-honest verdict on the diff.
sidq-mcp · skills/datahub-verify · action.yml · python -m sidq.bot.action
Nothing below is a placeholder. $PWD resolves while you stand in the checkout, so the MCP registration needs no path editing at all:
cd sidq && make install codex mcp add sidq \ --env DATAHUB_GMS_URL=http://localhost:8080 \ --env SIDQ_REPO_ROOT="$PWD" \ -- "$PWD/.venv/bin/sidq-mcp"
SIDQ_REPO_ROOT is the repository whose SQL gets gated. The Sidq checkout works for a first run; point it at your own repository afterwards.
The Skill is the one place a path is genuinely yours — it installs into the data repository, not into Sidq:
cd /the/data-repository-you-want-verified npx skills add NexuChat/sidq --skill datahub-verify --agent codex
01 The claim
A catalog can agree with itself—and still be wrong.
Catalog-only checks can expose contradictions inside metadata. They cannot detect a catalog that is internally consistent but no longer matches the source. Sidq crosses that boundary before an agent plans or writes.
DataHub says
raw.customers.email
Ingested, indexed, and what every agent planning against this table will read.
PostgreSQL says
raw.customers.email_address
Renamed by make demo-break. Nothing was re-ingested, which is exactly how this happens in production.
BLOCK — on the context, before the agent plans against a column that no longer exists.
One context. Three independent comparisons.
DataHub compared with DataHub
Six contradiction families across stored schema, field lineage, protection metadata, documentation, deprecation, and ownership.
DataHub compared with PostgreSQL
Schema fields, documented row-level promises, and the constraints the database actually enforces.
Stored lineage compared with SQL
Edges DataHub stores but SQL no longer produces—and edges SQL produces that DataHub never recorded.
- 5 claim types
- 6 catalog contradiction families
- 38 explicit policy rules
- 3 read-only MCP tools
The public claims run is the non-mutating live-source proof: DataHub prose becomes bounded SELECT-only SQL, and only returned row counts determine the verdict. The schema-rename demonstration remains in the local runbook because it deliberately changes PostgreSQL. Any missing comparison is reported as unverifiable, never clean.
Where a model is allowed to act
Turning a documented sentence into a testable claim is the one place a model earns its keep — and the only place it is allowed to act. A 16 KB linear head over frozen third-party embeddings proposes claims at 95.8% precision and 58.0% recall across 72 proposals, against a 17.4% rule baseline. It proposes only unique and not_null; a proposal that cannot be compiled to SQL is dropped; and it never emits a verdict. It may extend WARN coverage after the deterministic rules abstain, and it can never turn a PASS into a BLOCK.
285 contradictions.
In DataHub’s own showcase catalog.
A read-only audit found 285 instances of one contradiction family: stored lineage pointing to fields the destination datasets’ own schemas say do not exist — in showcase-ecommerce, the sample catalog DataHub itself ships. Here is one, whole.
Claim A · stored lineage
order_details.billing_address_line1
Customer_Analytics_Measures.BILLING_ADDRESS_LINE1
Claim B · that asset's own schema
Fields on Customer_Analytics_Measures:
Customer LTV · Value
The column the lineage names does not exist.
Both statements live inside the catalog. No source system was consulted. They cannot both be true.
And the same read says who wrote them. Attributing each side to its ingestion run — one query parameter, no extra calls — shows that the run which wrote the lineage is the run that wrote the schema, in all five affected assets and every stored version: ten out of ten. So this is not two sources disagreeing; one writer produced both halves in the same run. A later run reproduced the shape exactly rather than repairing it, which is why the finding names a job to fix rather than a state to refresh. How that was measured ↗
That is one of six catalog-self-checks. Sidq also checks for protection markers lost across field lineage, documentation naming absent columns, orphan edges, deprecated assets feeding live BI, and consumed assets with no owner. A bounded or incomplete read is reported as unverifiable, not clean.
It also decides what to look at. The audit ranks every asset by how much damage a lie about it would do and spends its budget on the worst first — then lets what it finds change the order: an asset caught in a contradiction promotes its neighbours ahead of their static score, because contradictions cluster. Same catalog, same sequence every time; a different catalog, a different one.
- 285
- internal contradictions
- 67
- datasets examined
- 5
- assets they cluster in
- 29
- consumed assets with no owner
02 The gate
No LLM decides whether the agent may act.
Five evidence gates feed one deterministic policy. A model may propose a bounded read-only test; only returned evidence can move the verdict.
Same agent. Same goal. Same catalog.
Only one of them is allowed to check.
Without Sidq
Reads the catalog, sees 22 columns including cust_email, and writes the query. Nothing here is careless — the catalog answered, and there was nothing else to ask.
select region_id, count(cust_email) from order_entry.customers group by region_id
With Sidq
Same code path, one addition: it verifies before it proposes. verify_context returns 0 findings and 24 checks it could not complete.
-- No SQL proposed. -- not checked: schema_drift (no live source configured) -- not checked: lineage_rot ×22 (no target-side column lineage) -- not checked: constraint_reconciliation
Read that refusal precisely. Offline, the agent did not catch the catalog lying — it produced zero findings. It stopped because three checks could not run at all, and it names each one instead of dropping it. That is the exact substitution this project exists to refuse: “we did not check” is not “we checked and it passed.” Both transcripts ↗
- Adaptive auditor
- Sidq MCP
- Agent Skill
- CLI
- GitHub Action
- PR bot
03 The refusal
An agent proposed removing cust_email.
Sidq refused — with evidence.
critical_downstream
Verified downstream ownership crosses team boundaries, including ORG_BACKEND_ENG and eight additional owners.
wide_blast_radius
16 downstream consumers recorded, including a live Looker dashboard.
PII_Data
The path carries PII metadata — supporting context, not the blocking cause.
Column-level lineage, followed hop by hop from the changed field to the thing that breaks:
- dbt
order_entry.customers / cust_email - Snowflake
analytics.order_details - Looker
explore.order_details - Dashboard
dashboards.53owned by another team
The same refusal, on real pull requests.
Four public pull requests on this repository carry the PR bot’s own verdicts, each read against a live DataHub, not a fixture. Two pass, two are refused with the rules named. One sticky comment per PR, rewritten in place rather than repeated.
- #1 harmless column ↗PASSno blocking or warning rule fired · verdict.json ↗
- #2 remove
cust_email↗BLOCKEDpii_exposure·critical_downstream· verdict.json ↗ - #3 stale catalog context ↗BLOCKED
unknown_field·catalog_reality_mismatch· verdict.json ↗ - #4 compatible migration ↗PASSproven against the live graph, with a receipt · verdict.json ↗
The checks on these four pull requests are red. The reason is published here rather than left to be found.
Both defects are in the bot that posts, not in the engine that decides. The workflow grants issues: write, but GitHub governs commenting on a pull request under pull-requests — so the bot is refused with 403 and the run fails. On #3 ↗ a second defect fires first: one graph recording is absent from the committed fixtures, and the doc-rot gate turned that unavailable catalog into a KeyError instead of a refusal to certify.
The verdicts above were read against a live catalog, and each is linked in full so it stands without the bot. Both fixes are written and held by the same rule as everything else here — the submission commit is frozen, so nothing has been pushed. A tool whose whole claim is that an unperformed check must never be reported as a clean one owes that standard to its own CI first.
04 The repair
Sidq rejects fixes that only move the failure.
A proposal is not a repair until the same deterministic engine re-runs against the catalog it would create.
A repair must survive its own audit.
Sidq repairs only what the catalog already proves. It can propagate a protection marker across the full downstream field-lineage closure, or assign an owner only when every owned upstream agrees. The other four contradiction families remain open with an explicit reason — because plausible metadata is still invented metadata.
A two-hop leak makes the point. source carries the PII marker; middle and sink do not.
Rejected
Tag middle#email only. The named finding is resolved — and Sidq still refuses it, because re-running every check against the catalog it would create surfaces a new pii_leak_untagged on sink. It resolved the ticket and moved the leak.
Proven
Tag the full field-lineage closure — middle and sink together, in one call. Every check re-runs, no new finding appears, and the set is verified jointly rather than one at a time.
This is a pinned regression, not an illustration: the one-hop refusal is the exact failure a live catalog produced.
- Propose
- Simulate
- Re-run every check
- Reject collateral
- Prove the surviving set together
- Write only with
--apply - Re-read live state
An acknowledged mutation stays open until direct readback proves the original finding is gone and no new one appeared.
05 The receipt
The next agent verifies for itself.
A receipt is not authority. A separate reader re-reads semantic metadata and complete one-hop lineage, then checks policy hash and age. Applicability, verdict, authorization, and coverage stay separate: a current BLOCK means “checked and refused” and still authorizes only STOP; absent, stale, or unreadable means RECHECK.
The verdict lands in DataHub’s own Quality tab.
Not a dashboard of Sidq's own. Each policy rule becomes a native DataHub Assertion through the documented custom-assertion API, reported by that rule's own severity, sitting beside the dataset's real owners and glossary terms — with sidq as the assertion platform.
One loop, through DataHub, end to end.
- Readgraph context through the official DataHub MCP server.
- Decideone deterministic policy returns PASS, WARN, or BLOCK. No model participates.
- Writeeach rule mirrors into DataHub’s own Quality tab as a native Assertion; a Receipt is persisted through official MCP only when asked.
- Read it backthe exact structured body, or the write stays
write_unconfirmed. - Re-verifya separate process re-reads the current context and judges again. An asset nobody examined stays NOT VERIFIED.
A mutation acknowledgement is not success. Sidq trusts a Receipt only after a direct read returns its exact structured body; a mismatch remains write_unconfirmed and triggers bounded compensation instead of becoming trusted state.
How the decision is reached — one picture
Gates collect evidence. One engine decides. No model participates in a blocking decision. Open full size ↗ · ARCHITECTURE.md ↗
06 The swarm
Four agents. No coordinator.
DataHub is the shared state.
Four concurrent auditors coordinate only through DataHub-native receipts—no IPC, message bus, lock service, or shared filesystem. Kill one mid-run; the others keep working, because nothing was ever assigned to it. A fifth, independent process reconstructs the ledger from DataHub alone.
The same receipts are also how Sidq remembers across time. An audit runs on a bounded budget, so a catalog larger than one budget used to re-examine its worst assets forever and defer the tail forever. sidq audit --resume re-reads the receipts earlier runs wrote back, re-derives whether each still holds under the current policy hash, and spends the budget on assets no run has reached — so repeated runs converge on coverage instead of circling. The memory lives in the catalog, not in a file beside the agent.
Recorded, not runnable here — and that is the point. Every proof button on this page runs a command that cannot write to a catalog. The swarm coordinates by writing, so it can never be one of them; a read-only swarm would demonstrate nothing. This is the verbatim output of make swarm-demo against a DataHub OSS v1.5.0.6 quickstart, run swarm-1786323944. Run it yourself with the five-command runbook.
- 4 workers
- 1 killed mid-run
- 14 distinct assets in 18 examinations
- 9 Receipts read by a process that examined nothing
The full recorded run
== four workers start together — no coordinator, no IPC == Swarm worker alpha examined 6 receipts written 6 vouched by peers 1 (beta) — their receipts held, so this worker moved on Swarm worker beta examined 6 receipts written 5 vouched by peers 1 (alpha) — their receipts held, so this worker moved on write failures 1 Swarm worker gamma examined 6 receipts written 3 vouched by peers 4 (alpha, beta) — their receipts held, so this worker moved on >> killed delta mid-run — its unfinished assets were never assigned to it == overlap, from the surviving workers' own reports == worker reports found 3 of 4 expected total examinations 18 distinct assets examined 14 duplicated examinations 4 == the ledger, read from DataHub by a process that audited nothing == current valid receipts 9 of 50 alpha 4 · beta 2 · gamma 3
The overlap and ledger answer different questions. Each worker records a digest for every examination; when two workers touch the same asset, Sidq checks whether their independent conclusions agree and surfaces divergence without choosing a winner. That overlap comes from worker reports — DataHub stores only the latest receipt and cannot count collisions after the fact. The ledger comes from a fifth process that trusts no worker and reads current receipts from DataHub alone. The first measurement of this run failed, at 6 distinct assets of 18 examinations, and two defects were found and fixed because the number existed.
07 Reproduce
Re-derive the refusal on your machine.
git clone https://github.com/NexuChat/sidq.git
cd sidq
make gate-demo
All commands and integrations
Nine subcommands, one engine. Read-only unless a flag says otherwise; sidq --help is the source of truth for flags.
sidq check- Gate one SQL file or a Git diff. Local read; no catalog mutation.
sidq explain- Explain one deterministic rule.
sidq audit- Hunt catalog contradictions. Read-only;
--write-receiptsand--write-assertionsare explicit opt-in,--resumeconverges coverage. sidq claims- Compile documented promises into bounded read-only SQL against the source.
sidq verify- Independently re-check a Receipt and its context.
sidq repair- Propose and prove repairs. Dry run by default; only
--applymutates. sidq swarm- Run one swarm worker. Writes current Receipts — an operator flow, never a public button.
sidq swarm-overlap- Measure worker overlap from local reports only.
sidq swarm-ledger- Read the swarm back out of DataHub as an independent observer.
The repository wraps the ones a reader is most likely to want. Two of the buttons above are literally these targets — Claims runs make claims-demo and the offline verdict runs make gate-demo; the other three call the sidq binary directly.
make doctor- Report what is present and what is missing before anything is run. Changes nothing.
make check- ruff, ruff format, mypy, then the whole suite with coverage — where the test count and coverage figure on this page come from.
make regen-check- Re-derive every generated artifact and fail if a committed copy has drifted from what the engine now produces.
make gate-demo- Re-derive the published verdict from the committed recording. Offline.
make claims-demo- Compile the documented promises and test them against live PostgreSQL.
make mcp-smoke- Prove the MCP server starts and exposes exactly the three read-only tools named above.
In CI, the same engine runs as a GitHub Action that publishes one sticky verdict comment and one check:
- uses: NexuChat/sidq@02969cb46a86c44a7b411ff98d9e05c4f6fd3c93
with:
token: ${{ secrets.GITHUB_TOKEN }}
mode: live # or: fixture
repo-root: .
The bot writes through the issue-comments endpoint, but its target is a pull request, so the workflow needs pull-requests: write. issues: write reads as sufficient from that endpoint path and is not — GitHub answers 403. The committed workflow and docs/PR-BOT.md ↗ still carry the narrower scope; that is the red check on the demo pull requests above, and the corrected permission is the one written here. Full setup in docs/SETUP.md ↗.
صِدق — Sidq — means truthfulness: claim only what the evidence can support.
Upstream evidence: a DataHub packaging defect was reported before the deadline in datahub#19017 ↗ and later fixed by an equivalent merged change; the datahub-verify Skill PR datahub-skills#81 ↗ is open and unmerged.
No DataHub or credentials — the replay bootstraps a hash-locked environment and re-derives the published verdict byte-for-byte. Then open examples/01-blocked-pii-dashboard/verdict.json and compare hashes.
Signing in to the live DataHub: sidq-judge@local.invalid / sidq-judge-2026. Published deliberately, and deliberately powerless — the account holds DataHub's Reader role and nothing else. It reads every asset, lineage graph and Sidq receipt; every write is refused with 403 UNAUTHORIZED, rechecked before submission. This form has no private field, so any credential a judge needs is public by necessity, which is exactly why the published one cannot change anything.