Inspiration
I do data work for more than one client from the same laptop. Two repos open, two warehouses in my shell history, and an agent that will happily reach into either one because from where it sits, both are just tables it has credentials for.
The thing that stuck with me is that the dangerous query is never malformed. It parses. The tables exist. You genuinely have access to both — that's why you were hired. A linter is happy, the warehouse is happy, and the agent thinks it just answered your question well:
SELECT l.email, p.phone
FROM northstar.marketing_leads l -- the client you're working for
JOIN bluepeak.patient_contacts p -- the one you're not
ON l.email = p.email
Nothing in that is wrong at the level of syntax, permissions, or intent. It's only wrong at the level of boundary — those tables belong to different clients, and one carries personal data at column level.
That is a question a catalog can answer instantly. Nothing in the loop was asking it.
What it does
Zence is a Claude Code plugin that sits in the hook path. Before a tool call runs, it:
- Normalizes the call into an action with an intent — read, write, mutate, destructive
- Extracts the assets it touches — SQL through a real parser, dbt
ref()andsource(), shell commands, YAML ingestion recipes, DataHub MCP tool arguments, file paths - Resolves them against DataHub — domain, ownership, tags at dataset and column level, glossary terms, lifecycle, environment, two hops of downstream lineage
- Evaluates deterministic policy against that evidence
- Returns one verdict — allow, ask, or deny — with the rule that fired, the DataHub URNs behind it, and a safe alternative
- Writes the decision back into DataHub as a durable document
A real denial looks like this. Every fact in it came from the catalog:
✗ DENY ZR-001 Cross-client PII access
bluepeak.patient_contacts belongs to BluePeak Health, but this session is
bounded to Northstar Commerce. It carries urn:li:tag:PII, and columns email,
phone, postcode are classified at field level. Reading it here would move
another client's personal data into this workspace.
→ Use an asset inside Northstar Commerce. If you genuinely need to work on
BluePeak Health, switch to that client's workspace so the correct boundary
applies.
evidence urn:li:dataset:(urn:li:dataPlatform:snowflake,bluepeak.patient_contacts,PROD)
Three verdicts, not two, and the middle one is the interesting one. Cross-client PII gets denied. A change to a shared model that feeds a critical dashboard gets an ask, with the lineage path named. In-boundary work in a dev environment is allowed silently — no prompt, no banner, nothing. A guardrail that announces itself on safe work is one people uninstall in a week.
How I built it
DataHub is load-bearing on both the read and the write path, through two different surfaces.
The MCP Server is the surface Zence intercepts. Claude reads the catalog
through mcp-server-datahub, so a cross-client lookup shows up in the hook path
before that metadata ever reaches the model's context. The PreToolUse matcher
is mcp__.*datahub.*__.*, which covers both a user's own server and the one the
plugin bundles.
The Python SDK is what Zence reasons from. A hook can't borrow the agent's MCP connection, and enforcement needs typed aspects — not prose shaped for a model to read.
On the write side, at session end Zence upserts one decision document with a
deterministic id, sha256(workspace::session), linked to every asset the
session touched, plus a zence.last_review structured property. Finalize ten
times, get one document. The idempotency is structural rather than a check that
can lose a race.
What Zence deliberately does not write: it never retags, reclassifies, or reassigns ownership of a client's assets. Those are your team's calls. An agent guardrail that quietly edits someone's catalog has become a different and much worse product.
The engine is deterministic and boring on purpose. Twelve rules, ten
operators, policy as YAML data. No expression language, no eval, and no model
anywhere in the decision path. A model may help classify what a prompt is
about; it never decides what's allowed. That's what makes a denial arguable
rather than merely asserted — same inputs, same decision, every time, and you
can read the rule that produced it.
Precision turned out to be a safety property. An extractor that reports table aliases and CTE names as real datasets produces a prompt on every action, people learn to click approve without reading, and the guardrail becomes a formality. The must-not-find tests outnumber the must-find ones.
The stack: Python 3.11, acryl-datahub, mcp-server-datahub, sqlglot for SQL,
Pydantic for schemas, SQLite for the audit trail, Typer and Rich for the CLI.
The site is a static Next.js export on Vercel that renders real JSON artifacts
from actual runs — CI fails the build if anyone edits one by hand.
Challenges I ran into
Almost all the hard work was in the failure modes. Getting allow right is
easy. Getting "I couldn't check, so I'm asking" right — without being so noisy
that people stop reading — is where the time went.
Three bugs sat on the same fault line: treating "I couldn't look it up" as "it isn't there."
_find_urncaught a connection error and returnedNone, which the caller read as "not in the catalog." A DataHub outage was indistinguishable from a clean bill of health. That is precisely the failure this product exists to prevent, sitting in my own code.- Rules fired against unresolved evidence. A failed lookup leaves
domain_urnasNone, which satisfies "not in allowed_domains" — so Zence produced a confident cross-client finding from no evidence at all, while the honest "couldn't reach DataHub" message never appeared. - The SDK's default retry-with-backoff took 28 seconds to give up on a dead endpoint, inside a hook that needs to answer in a couple of seconds. A DataHub outage would have looked like Claude Code hanging.
The bug only a live catalog could find. Dataset.tags doesn't return URN
strings. It returns TagAssociationClass objects, and str() on one gives you
TagAssociationClass({'tag': 'urn:li:tag:PII', ...}) — which never equals
urn:li:tag:PII. So ZR-001 could not fire against a real DataHub. The
cross-client PII denial, the entire point of the project, did nothing outside of
tests. Every unit test agreed with the broken code, because a recorded fixture
stores plain strings.
zence demo verify caught it on the first run against the real instance — ten
problems, every one a missing tag. I'd written that command because seeding a
catalog is a batch of upserts that mostly succeeds, and "mostly" is how a demo
dies in front of an audience. It earned its place in about four minutes.
And the worst one, found two weeks in. The plugin builds its own virtualenv
on first run. It was installing the runtime without the optional DataHub SDK —
so a Zence installed from the marketplace had no SDK, every lookup failed, and
the fail-safe correctly turned every decision into ask.
It looked like it was working. Prompts appeared, the reasons were accurate, nothing crashed. All 387 offline tests passed, because those tests are supposed to run without the SDK. The only way to see it was to run the shim against a live catalog and actually read the reason string. A contract test now asserts the install line carries the extra, because I'd rather never find that twice.
I also wrote my own ReDoS. The secret-redaction pass I added to keep tokens out of the audit log used unbounded quantifiers; 10 KB of adversarial input took 2.4 seconds. The thing protecting the logs became the way to hang the hook.
Accomplishments that I'm proud of
Scenario A — the cross-client PII join — is denied before execution, with the offending columns named, the DataHub URN as evidence, and an in-domain alternative offered. Scenario B asks, and names the revenue dashboard it found two lineage hops downstream. Scenario C allows, silently. All verified against a live DataHub instance, not a mock.
A judge needs nothing but uv to see it. The demo workspace ships a catalog
recording captured from that live instance, so a fresh clone produces the real
denial — no Docker, no catalog, no account, no waiting. And a recording can
never impersonate a catalog: every decision it produces is stamped
provider: fixture, and a DATAHUB_GMS_URL in your environment always wins
over it. A workspace with neither still answers ask, because ignorance must
never quietly become permission.
Every claim on the website is a JSON artifact from a real run, and CI fails the build if one is edited by hand.
402 tests. mypy strict. Eight CI jobs. Secret scanning across full history with no allowlist — an allowlist is just a place a real secret can hide.
What I learned
Most of a guardrail's value is in what it does when it's broken. Every serious bug I hit was some flavour of the same mistake — a thing that couldn't be checked being treated as a thing that was fine.
Tests that pass without the dependency will happily pass without the feature. My offline suite was green while the shipped plugin couldn't read DataHub at all. That's not an argument against offline tests; it's an argument that "it works" has to be observed on the real path at least once.
Verify the platform instead of remembering it. astral-sh/setup-uv has no
moving v9 tag. claude plugin validate silently skips plugin.json when a
marketplace manifest sits next to it. Tailwind v4 dropped the arbitrary-value
colour shorthand, so an entire palette rendered as nothing while the build
stayed green. Not one of those would have been caught by assuming.
And say what you don't do. The threat model opens with what Zence isn't. It doesn't intercept a shell, it isn't a sandbox, and it doesn't replace warehouse grants. Stating that plainly costs nothing and is the whole difference between a tool you can trust and one you have to second-guess.
What's next for Zence
- Approval routing, so an
askcan reach the person who actually owns the asset instead of stopping at whoever is at the keyboard - More extractors. Today it's SQL, dbt, shell, YAML recipes, MCP arguments and paths — and the README says exactly that rather than implying more
- Proposals API write-back, so Zence can suggest a classification it inferred rather than only recording its own decisions
- A skill contributed upstream to
datahub-project/datahub-skillscovering domain-boundary and PII-aware asset selection, which fell out of building this
Built With
- acryl-datahub
- anthropic
- claude-code
- datahub
- github-actions
- mcp
- mypy
- nextjs
- pydantic
- pytest
- python
- react
- rich
- ruff
- sqlglot
- sqlite
- tailwindcss
- typer
- typescript
- uv
- vercel
- yaml

Log in or sign up for Devpost to join the conversation.