Context Engineering for Enterprise AI, Part 6: AI & Data Governance — The Foundation Everything Grows From
Part 6: AI & data governance as a control plane — classification at ingestion, entitlement-aware retrieval, consent & purpose limits, data lineage, and policy-as-code in C# + Python.
- Author
- Randhir Jassal
- Published
- Reading time
- 28 min read
- Views
- 17 views
This is Part 6 in Context Engineering for Enterprise AI — the governance foundation. Parts 1–5 built capability for Mattrx, a multi-tenant marketing-analytics SaaS (110k MAU, ~9,000 tenants, ~3,200 req/sec peak, ASP.NET Core / .NET 9 on Azure SQL plus a Python (FastAPI) AI compute service): a budgeted context window, a memory layer, multi-agent orchestration, an enterprise design spine, and multi-tenant isolation. This part is the layer underneath all of them — the control plane that decides what data is allowed to enter or leave the context at all, for whom, and for what purpose. Capability without governance is a breach waiting for a date. Governance isn't the compliance chore you bolt on before an audit; it's the soil everything else grows from.
Recap
The series so far enforced isolation and quality in pieces: Part 2 — The Memory Layer made memory tenant-isolated and forgettable; Part 4 — Enterprise AI Design added eval gates, prompt-injection / PII defense at the boundary, and cost tracing; Part 5 — Multi-Tenant Patterns made the tenant boundary hold across the whole pipeline.
This part is deliberately not a rehash of those. To keep the boundaries crisp:
- Part 2 owns memory isolation and right-to-be-forgotten mechanics — not repeated here.
- Part 4 owns injection defense, eval gates, and cost/token tracing — not repeated here.
- Part 5 owns tenant-vs-tenant isolation, RLS, and cost attribution — not repeated here.
- Part 6 owns the data-governance control plane beneath all three: classifying data at ingestion, authorizing retrieval at the principal (user/role) level within a tenant, limiting use by consent and purpose, recording data lineage, and centralizing every rule as policy-as-code. Parts 2/4/5 each enforced one rule in one place; this part gives them a single engine to ask.
TL;DR
Governance is a control plane, not a checklist. Five controls decide what may enter or leave the context, each answering a different question and each enforced before the model sees data, never after a breach:
| Control | The question it answers | Where Mattrx enforces it |
|---|---|---|
| Classification | How sensitive is this data? | A classify gate at ingestion; class + purpose tags written to the Data Catalog |
| Entitlement-aware retrieval | May this user (not just this tenant) see it? | Principal ACL/group filter stacked on the Part 5 tenant filter |
| Consent & purpose | Are we allowed to use it for AI at all? | Purpose tags + a consent registry checked at use time; opt-outs excluded from embedding/eval |
| Lineage & provenance | What exactly did the model see, and why was it allowed? | An append-only lineage record per generation: output → sources(version, class) → principal → purpose → decisions |
| Policy-as-code (PDP) | Who decides, and can we prove the rule? | One deny-by-default Policy Decision Point every path calls — versioned, tested, cached |
Mattrx production results after standing up the governance control plane (3-week build, 4 backend engineers + 1 SRE + 1 security/privacy lead):
- Confidential/Restricted documents that reached the shared embedding store ungoverned: ~3,100 -> 0 (classification gate blocks or reroutes them at ingestion).
- Intra-tenant cross-principal leaks (a support agent retrieving a finance doc inside their own tenant) in red-team testing: reproducible -> 0 (entitlement-aware retrieval).
- "What did the model see about data subject X, and why was it allowed?" — answerable for 0% -> 100% of generations; DSAR fulfillment ~2 days of log spelunking -> under 3 minutes (one lineage query).
- Customer data used for eval or fine-tuning without a consenting purpose: unbounded/unknown -> 0 (purpose gate excludes opt-outs).
- Governance logic centralized from ~14 scattered enforcement sites -> 1 PDP + N versioned policies; a policy change now ships without touching service code.
- Governance overhead on the retrieval hot path: +4 ms p95 (PDP decision p95 ~3 ms cached, deny-by-default).
- Retrieval p95 31 ms -> 35 ms, recall@5 held at 0.94; cost per AI query $0.008 (unchanged); C# app API p95 120 ms -> 124 ms.
- Time to answer an auditor's "who accessed what, and under which rule": hours -> one query.
The one mental shift
Governance is a control plane that runs before retrieval, not a report you generate after an incident. Make every data access a question —
can(principal, data, purpose, context)?— answered deny-by-default by one engine, and recorded — so the answer to "could this leak?" is a query, not a prayer.
The wrong model is "we'll add governance later": rules accreting as scattered if statements, each service deciding for itself what's allowed, nobody able to reconstruct why an answer contained what it did. The right model is a single Policy Decision Point that ingestion, retrieval, and output all consult, fed by classification, entitlements, and consent, with every decision written to an append-only lineage. Governance stops being paperwork and becomes a property of the pipeline — the foundation Parts 1–5 quietly stood on.
The running example
Mattrx's data is not uniform, and that's the whole point. A single tenant's workspace holds campaign performance data (Internal), customer contact lists with PII (Confidential), billing and revenue (Confidential), connected-platform access tokens (Restricted — must never enter a prompt), and public help docs (Public). Inside one tenant there are distinct principals: analysts, workspace admins, a finance role, and support agents — and a support agent has no business retrieving that tenant's revenue breakdown, even though it's their own tenant's data.
On top of that, some tenants are opt-out (contractually, their data may power their features but may never be used to improve the product — no eval sets, no fine-tuning), and some are regulated (EU residency from Part 5, plus stricter retention). Both AI products feel this: Mattrx Help must classify and trim docs so a Public-tier reader never retrieves a Confidential runbook; Mattrx Insights (the agent from Part 3) must check purpose before it analyzes a customer list. As always, C# (ASP.NET Core + Azure SQL) owns the governance decisions; Python (FastAPI + Azure OpenAI + Azure AI Search) executes within them and never decides what it is allowed to touch.
The governance control plane
One picture: classification labels data on the way in, a single Policy Decision Point authorizes every access deny-by-default, and an append-only lineage store records what happened — so the boundary is enforced and provable.
THE GOVERNANCE CONTROL PLANE
deny-by-default — no data enters or leaves the context unchecked
┌── INGEST ───────────────────────────────────────────────────────────────┐
│ docs · connectors · events │
│ │ classify: sensitivity class + purpose tags + source │
│ ▼ │
│ Data Catalog ── registers ──▶ asset { class, purpose, version, owner } │
└───────┬──────────────────────────────────────────────────────────────────┘
│
▼ ┌──────────────────────────────────────┐
┌────────────────┐ consults │ POLICY DECISION POINT (PDP) │
│ RETRIEVE │ ─────────────▶ │ can(principal, data, purpose, ctx)? │
│ tenant (P5) │ │ deny-by-default · policy-as-code │
│ ∧ principal │ ◀── allow/deny │ versioned · tested · cached (~3 ms) │
│ ∧ class │ └────┬──────────────┬─────────────┬─────┘
│ ∧ purpose │ feeds │ feeds │ feeds │
└───────┬────────┘ ┌─────────┴──┐ ┌────────────┴──┐ ┌────────┴─────┐
│ │ Entitlements│ │ Consent │ │ Data Catalog │
▼ │ service │ │ registry │ │ (class) │
assemble ▶ model └─────────────┘ └───────────────┘ └──────────────┘
│
▼
┌────────────────┐ every generation writes ▼
│ OUTPUT │ ──────────────────────────▶ Lineage store (append-only):
│ egress check │ output → sources(version,class)
└────────────────┘ → principal → purpose → decisions
Governed planes: Azure AI Search (vectors) · Azure SQL · Azure OpenAI
1. Classification at ingestion: you cannot govern what you never labeled
Before
Everything a tenant connected was embedded into the vector store the moment it arrived. No sensitivity label, no purpose, no idea that a "knowledge base" sync had just vectorized a spreadsheet of customer emails and a file of platform API tokens — now one cosine hop away from any prompt.
# BEFORE — ingest.py. Embed whatever arrives. No class, no purpose, no gate.
async def ingest(doc: dict) -> None:
vector = await embed(doc["text"]) # tokens, PII, runbooks — all of it
await search_client.upload_documents([{
"id": doc["id"], "tenant_id": doc["tenant_id"],
"content": doc["text"], "content_vector": vector,
}])
# Result: Restricted data sitting in the same index as public help docs.
Governance that starts at retrieval is already too late — the sensitive data is embedded, copied, and indexed. The only place to stop it is the front door.
After
A classify gate runs at ingestion. Every asset is assigned a sensitivity class and purpose tags, registered in a Data Catalog, and then routed: Public/Internal into the pool index, Confidential into a restricted index with stricter retrieval policy, and Restricted never gets embedded at all — it is catalogued and quarantined, not vectorized.
# AFTER — classify.py. Label and decide BEFORE anything is embedded.
class SensitivityClass(str, Enum):
PUBLIC = "public"; INTERNAL = "internal"
CONFIDENTIAL = "confidential"; RESTRICTED = "restricted"
async def classify(doc: dict) -> Classification:
# Cheap deterministic detectors first (regex/Presidio for secrets, PII),
# LLM classifier only for the ambiguous remainder — cost and determinism.
if SECRET_RE.search(doc["text"]) or TOKEN_RE.search(doc["text"]):
return Classification(SensitivityClass.RESTRICTED, ["none"]) # never embed
pii = detect_pii(doc["text"]) # Presidio
if pii:
return Classification(SensitivityClass.CONFIDENTIAL, ["product"], pii=pii)
label = await llm_classify(doc["text"]) # gpt-4o-mini, schema-constrained
return Classification(label.cls, label.purposes)
// AFTER — IngestionGate.cs. Catalog every asset; route by class; quarantine Restricted.
public async Task IngestAsync(TenantScope scope, RawAsset asset, CancellationToken ct)
{
var c = await _classifier.ClassifyAsync(asset, ct);
// System-of-record: the catalog row is written for EVERY asset, embedded or not.
var assetId = await _catalog.RegisterAsync(new DataAsset
{
TenantId = scope.TenantId, Source = asset.Source, Version = asset.Version,
Class = c.Class, Purposes = c.Purposes, ContainsPii = c.Pii.Any(),
}, ct);
if (c.Class == SensitivityClass.Restricted)
{
// Catalogued and quarantined — NEVER embedded. This is the whole point.
_log.LogWarning("ingest.quarantine tenant={T} asset={A} class=Restricted",
scope.TenantId, assetId);
return;
}
var index = c.Class == SensitivityClass.Confidential
? IndexNames.Restricted(scope) // stricter retrieval policy (Section 2)
: IndexNames.Pool(scope); // Public / Internal -> pool (Part 5)
await _embedder.EmbedAndUploadAsync(index, assetId, asset, ct);
}
Why classify at ingestion, not retrieval. Retrieval-time classification means the sensitive bytes already live in the index — every backup, every replica, every "oops, forgot the filter" is now a potential leak. Classifying at the front door means Restricted data is never embedded, so a missing filter downstream can't expose what was never there. Disadvantage: classification can be wrong (a misclassified Confidential doc lands in the pool, or a benign doc is over-quarantined), and re-classifying when a policy changes means re-scanning the corpus. We accept it because the failure is bounded and auditable — every asset has a catalog row you can re-evaluate — whereas an un-labeled corpus has no failure boundary at all.
Diagnostic — prove no Restricted asset ever got a vector:
$ sqlcmd -Q "SELECT COUNT(*) FROM dbo.DataAssets a \
JOIN dbo.EmbeddedChunks e ON e.AssetId = a.Id \
WHERE a.Class = 'restricted';"
# 0 — Restricted is catalogued but never embedded. Anything > 0 is a gate bug.
Mattrx metric: the ingestion gate moved ~3,100 Confidential/Restricted documents out of the shared embedding store — Restricted (tokens, secrets) quarantined entirely, Confidential rerouted to the restricted index — taking ungoverned-sensitive-vectors from thousands to 0, with a 0.4% over-quarantine rate that a human reviews from the catalog.
2. Entitlement-aware retrieval: authorize the principal, not just the tenant
Before
Part 5 made retrieval tenant-scoped — tenant A never sees tenant B. But inside a tenant, retrieval returned anything that tenant owned, to anyone in it. A support agent asking Mattrx Help a billing question pulled the tenant's revenue runbook; the model summarized numbers the agent was never authorized to see. The tenant filter was correct and insufficient.
# BEFORE — tenant-scoped only. Correct for tenant isolation (Part 5), blind to the user.
async def search_docs(scope: TenantScope, qvec, k: int):
return await pool_index.search(
vector_queries=[VectorizedQuery(vector=qvec, k_nearest_neighbors=k,
fields="content_vector")],
filter=f"tenant_id eq '{scope.tenant_id}'", # tenant, yes. principal, no.
top=k,
)
After
Stack a principal predicate on top of the tenant predicate: the requesting user's groups/roles must intersect the document's allowed-groups ACL. This is intra-tenant authorization — a different boundary from Part 5's tenant-vs-tenant, and both filters apply at once. Mattrx runs a hybrid: a fast ACL filter in the index, then a revalidation of the surviving top-k against the live entitlements service so a just-revoked permission can't leak through a stale index.
# AFTER — retrieval.py. tenant ∧ principal ∧ class ∧ purpose. Security trimming.
async def search_docs(principal: PrincipalScope, qvec, k: int):
groups = "','".join(principal.group_ids) # caller's live groups (server-side)
flt = (
f"tenant_id eq '{principal.tenant_id}' "
f"and sensitivity le {principal.clearance} " # class ceiling for the role
f"and allowed_groups/any(g: search.in(g, '{groups}'))" # ACL intersection
)
# Over-fetch so revalidation can drop a few without starving recall.
hits = await pool_index.search(
vector_queries=[VectorizedQuery(vector=qvec, k_nearest_neighbors=k * 3,
fields="content_vector")],
filter=flt, top=k * 3,
)
# Late-binding revalidation: confirm each survivor against the live ACL.
fresh = await entitlements.filter_visible(principal, [h["id"] for h in hits])
return [h for h in hits if h["id"] in fresh][:k]
// AFTER — PrincipalScope.cs. TenantScope (Part 5) + who the user actually is.
public sealed record PrincipalScope
{
public required TenantScope Tenant { get; init; }
public required Guid UserId { get; init; }
public required IReadOnlySet<string> GroupIds { get; init; } // from IdP / SCIM, server-side
public required int Clearance { get; init; } // role -> max sensitivity
public required string Purpose { get; init; } // why this request exists
}
The three ways to do principal-level trimming, and why Mattrx runs the hybrid:
| Approach | How it works | Freshness on ACL change | Latency | Best for |
|---|---|---|---|---|
| Filter-in-index | Store allowed_groups on each chunk; filter in the vector query | Stale until re-index | Fastest (one query) | Large corpora, slow-changing ACLs |
| Late-binding | Over-fetch, then check every hit against the live entitlements service | Always fresh | Slower (N checks); recall risk if N too small | Small result sets, fast-changing ACLs |
| Hybrid | Index filter for speed, revalidate the surviving top-k live | Fresh for what you return | Fast + correct | Mattrx's choice |
Why hybrid. Pure filter-in-index is fast but a revoked permission keeps leaking until the next re-index — unacceptable for "remove this person's access now." Pure late-binding is always correct but checks far too many candidates and risks trimming away real recall. Hybrid filters cheaply in the index, then revalidates only the handful it would actually return — fast path for the 99%, live correctness for the result set. Disadvantage: you must keep the index ACLs roughly in sync (a re-index job) and run a live check, so there are two systems to operate; and an over-fetch multiplier (k*3) that you tune against recall.
Diagnostic — same tenant, different principals, different results:
# Finance role and Support role ask the identical question in the SAME tenant.
$ curl -s -H "Authorization: Bearer $FINANCE_JWT" -d '{"q":"Q3 revenue by region"}' \
$API/api/help/search | jq 'length' # 5 (entitled)
$ curl -s -H "Authorization: Bearer $SUPPORT_JWT" -d '{"q":"Q3 revenue by region"}' \
$API/api/help/search | jq 'length' # 0 (same tenant, not entitled)
Mattrx metric: stacking the principal ACL on the tenant filter closed every intra-tenant cross-role retrieval (reproducible -> 0 in red-team), while the hybrid revalidation held recall@5 at 0.94 and added only +4 ms p95 (retrieval 31 ms -> 35 ms).
3. Consent and purpose limitation: allowed to have it isn't allowed to use it
Before
If Mattrx stored a piece of data, every AI feature treated it as fair game — retrieval, agent analysis, eval sets, even fine-tuning experiments. But "we hold this data to run the customer's campaigns" is not consent to "use it to improve our product." Opt-out tenants' rows sat in the same eval and embedding pipelines as everyone else's, with nothing enforcing the difference.
// BEFORE — if we have the data, we use it. No purpose, no consent, no opt-out.
var dataset = await db.CustomerRecords
.Where(r => r.TenantId == scope.TenantId)
.ToListAsync(ct);
await _evalHarness.BuildGoldenSet(dataset); // includes opt-out tenants. Silently.
After
Every asset carries purpose tags (set at classification), and a consent registry records what each tenant/subject has and hasn't agreed to. Each AI use names its purpose — serve, eval, train — and the PDP (Section 5) refuses data whose purposes don't include it. Opt-out tenants are structurally excluded from eval/train, while still fully served their own serve-purpose features.
// AFTER — PurposeGate.cs. Use is allowed only if purpose ∈ asset purposes ∧ consent.
public async Task<IReadOnlyList<T>> ForPurposeAsync<T>(
PrincipalScope principal, Purpose purpose, IQueryable<T> source, CancellationToken ct)
where T : IGovernedRow
{
var rows = await source.ToListAsync(ct);
var allowed = new List<T>(rows.Count);
foreach (var r in rows)
{
var decision = await _pdp.CheckAsync(principal, r.AssetId, purpose, ct);
if (decision.Allow) allowed.Add(r);
else _log.LogInformation("purpose.deny asset={A} purpose={P} reason={R}",
r.AssetId, purpose, decision.Reason); // e.g. "tenant opted out of train"
}
return allowed;
}
# AFTER — embed_pipeline.py. Opt-out data is never even embedded for product purposes.
async def embeddable(asset: DataAsset, consent: ConsentRegistry) -> bool:
if "product" not in asset.purposes: # not tagged for product use
return False
if consent.opted_out(asset.tenant_id, purpose="product"):
return False # contractually serve-only
return True
Why purpose limitation is its own control. Classification (Section 1) answers how sensitive — purpose answers what you may do with it, and they're orthogonal: a Public doc may still be off-limits for training, and a Confidential doc may be fully usable to serve the tenant who owns it. Conflating them is how a perfectly-classified system still trains on data it had no right to. Disadvantage: purpose tags must be assigned correctly at ingestion and kept current as contracts change, and an over-strict purpose policy starves your eval sets — Mattrx maintains a consented, representative eval corpus precisely so opt-outs don't degrade quality measurement.
Diagnostic — confirm opt-out tenants never enter the improvement pipelines:
$ az monitor log-analytics query -w $LAW_ID --analytics-query \
"AppTraces | where Message == 'purpose.deny' and Properties.purpose in ('eval','train') \
| summarize denied=count() by tostring(Properties.tenant)"
# Opt-out tenants show their full row counts denied; consented tenants show 0 denied.
Mattrx metric: the purpose gate took customer data used for eval/fine-tuning without a consenting purpose from unbounded/unknown to 0, while opt-out tenants kept 100% of their serve-purpose features — governance that subtracts misuse without subtracting product value.
4. Lineage and provenance: prove exactly what the model saw
Before
When a customer filed a data-subject access request ("what does your AI know about me, and where did it come from?"), or security asked "which source produced this leaked figure?", the answer was a multi-day archaeology dig through stateless logs. Part 4 traced cost and tokens; nothing traced which classified sources fed which answer. An output was unattributable.
// BEFORE — return the answer, remember nothing about its provenance.
var answer = await _chat.CompleteAsync(prompt, ct);
return answer.Text; // which docs? whose data? which policy allowed it? unknown.
After
Every generation writes one append-only lineage record: the output hash, the exact source assets (id, version, class) that entered the prompt, the principal, the declared purpose, and the PDP decisions that allowed each source. Provenance becomes a single query — for DSAR, for incident forensics, for proving a regulated tenant's data never crossed a boundary.
// AFTER — Lineage.cs. One immutable record links an answer to everything behind it.
public sealed record GenerationLineage
{
public required Guid GenerationId { get; init; }
public required Guid TenantId { get; init; }
public required Guid PrincipalId { get; init; }
public required string Purpose { get; init; }
public required string PromptHash { get; init; } // not the prompt text
public required string Model { get; init; }
public required IReadOnlyList<SourceRef> Sources { get; init; } // id, version, class
public required IReadOnlyList<PolicyDecisionRef> Decisions { get; init; }
public required DateTimeOffset At { get; init; }
}
// Written on every generation, off the hot path, to an append-only (insert-only) table.
public async Task RecordAsync(GenerationLineage l, CancellationToken ct)
{
await _ledger.AppendAsync(l, ct); // no UPDATE/DELETE grant on this table — audit-grade
_telemetry.GenerationLineageWritten(l.GenerationId, l.Sources.Count);
}
-- AFTER — answer a DSAR in one query: every source touching a subject's data.
SELECT g.GenerationId, g.At, g.PrincipalId, g.Purpose, s.AssetId, s.Version, s.Class
FROM GenerationLineage g
CROSS APPLY OPENJSON(g.Sources) WITH (AssetId uniqueidentifier '$.id',
Version int '$.version', Class varchar(20) '$.class') s
JOIN DataAssets a ON a.Id = s.AssetId
WHERE a.TenantId = @TenantId AND a.SubjectId = @SubjectId
ORDER BY g.At DESC;
-- From "two days of log spelunking" to a single, auditable result set.
Why lineage is governance, not just observability. Part 4's tracing answers how much did this cost; lineage answers what data, whose, under which rule — the questions a regulator, a DSAR, or a breach post-mortem actually ask. It must be append-only (no row can be edited to rewrite history) and record references and versions, not raw content (so the audit trail isn't itself a second copy of the sensitive data). Disadvantage: lineage storage grows with every generation and must have its own retention policy, and writing it adds work — Mattrx writes it asynchronously off the response path so it never adds user-facing latency.
Diagnostic — confirm coverage is total, not best-effort:
$ az monitor log-analytics query -w $LAW_ID --analytics-query \
"AppRequests | where Name == 'generate' \
| summarize gens=count(), with_lineage=countif(Properties.lineage_written == 'true')"
# gens == with_lineage -> 100% coverage. Any gap is a generation we can't audit.
Mattrx metric: append-only lineage took provenance coverage from 0% to 100% of generations and cut DSAR fulfillment from ~2 days to under 3 minutes, with 0 measurable latency added to responses (the write is off the hot path).
5. Policy-as-code: one decision point everything asks
Before
Each control above — and each rule in Parts 2/4/5 — lived as its own if statements in its own service. Tenant checks in the API, ACL checks in the retriever, consent checks in the eval job, residency checks in the data layer. Fourteen enforcement sites, four languages of "allowed," and no single place to read, test, or change the rules. Drift was inevitable: one service tightened a rule, another forgot.
// BEFORE — governance as scattered branches. Drifts the moment two of them disagree.
if (doc.TenantId != scope.TenantId) return Forbid();
if (doc.Class == "restricted") return Forbid();
if (doc.Class == "confidential" && role != "finance") return Forbid();
// ...repeated, slightly differently, in the retriever, the eval job, the agent tools.
After
A single Policy Decision Point answers one question — can(principal, data, purpose, context)? — deny-by-default, with the rules expressed as versioned, tested policy-as-code (Mattrx runs OPA/Rego as a sidecar; a typed C# engine works too). Ingestion, retrieval, output, and the agent tools all ask the same PDP, and every answer is cached and recorded.
# AFTER — policy/access.rego. The rules live in ONE place, versioned and tested.
package mattrx.access
default allow := false # deny-by-default
allow if {
input.data.tenant_id == input.principal.tenant_id # tenant (Part 5)
sensitivity_ok
input.purpose in input.data.purposes # purpose limitation
not opted_out
}
sensitivity_ok if input.data.sensitivity <= input.principal.clearance
sensitivity_ok if { # confidential needs the group
input.data.sensitivity == 3
input.data.allowed_groups[_] == input.principal.group_ids[_]
}
opted_out if data.consent.optouts[input.data.tenant_id][input.purpose]
// AFTER — Pdp.cs. Every path asks the same engine; the answer is cached + recorded.
public async Task<PolicyDecision> CheckAsync(
PrincipalScope p, Guid assetId, Purpose purpose, CancellationToken ct)
{
var asset = await _catalog.GetAsync(assetId, ct); // class + purposes
var key = PolicyCacheKey(p, asset, purpose); // memoize hot decisions
if (_cache.TryGet(key, out var cached)) return cached;
var decision = await _engine.EvaluateAsync(new PolicyInput(p, asset, purpose), ct);
_cache.Set(key, decision, TimeSpan.FromSeconds(30)); // short TTL: rules change
_audit.PolicyEvaluated(p.UserId, assetId, purpose, decision); // feeds lineage (Section 4)
return decision;
}
Why centralize into a PDP. Scattered rules cannot be audited, tested, or changed safely — you can't prove a property of a system whose policy is smeared across fourteen files. One deny-by-default engine gives you a single source of truth, unit-testable policies, change management (a rule is a versioned commit, not a redeploy of five services), and a natural choke point to record every decision into lineage. Disadvantage: the PDP is now on the hot path, so its latency and availability matter — Mattrx runs it as a co-located sidecar with a 30-second decision cache and a deny-by-default fail-closed posture, accepting that a PDP outage blocks access rather than risking an open one. That's the right failure mode for governance, but it's a real availability coupling you must design for.
Diagnostic — prove the policy itself, in CI, before it ships:
# Policies are tested like code. A failing case blocks the deploy.
$ opa test policy/ -v
# data.mattrx.access.test_support_cannot_read_finance: PASS
# data.mattrx.access.test_optout_tenant_excluded_from_train: PASS
# data.mattrx.access.test_restricted_never_allowed: PASS
# PASS: 27/27
Mattrx metric: consolidating governance into one PDP took enforcement from ~14 scattered sites to 1 engine + N versioned policies — policy changes now ship as a tested commit without touching service code — at a ~3 ms cached decision cost and +4 ms p95 on the retrieval path.
Aggregate metrics
| Metric | Before (governance bolted on) | After (governance as control plane) |
|---|---|---|
| Confidential/Restricted docs embedded ungoverned | ~3,100 | 0 |
| Intra-tenant cross-principal retrieval leaks (red-team) | reproducible | 0 |
| Provenance coverage (generations with lineage) | 0% | 100% |
| DSAR "what did the model see, and why allowed?" | ~2 days (manual) | < 3 min (one query) |
| Customer data used for eval/train without consent | unbounded / unknown | 0 |
| Governance enforcement sites | ~14 scattered | 1 PDP + N versioned policies |
| PDP decision p95 (cached, deny-by-default) | n/a | ~3 ms |
| Retrieval p95 (added governance) | 31 ms | 35 ms (+4 ms) |
| recall@5 with principal + class + purpose filters | 0.94 | 0.94 (held) |
| Over-quarantine rate (false Restricted) | n/a | 0.4% (human-reviewed) |
| Cost / AI query | $0.008 | $0.008 (unchanged) |
| C# app API p95 | 120 ms | 124 ms |
| Time to answer "who accessed what, under which rule" | hours | one query |
Pre-ship checklist
- Every ingested asset is classified (sensitivity + purpose) and written to the Data Catalog before anything is embedded.
- Restricted data (secrets, tokens) is catalogued and quarantined — never embedded; a query proves zero Restricted vectors exist.
- Retrieval stacks a principal predicate (groups/clearance) on the Part 5 tenant predicate; both apply, every time.
- Entitlement trimming revalidates the returned top-k against the live entitlements service, so a revoked permission can't leak through a stale index.
- Every AI use declares a purpose (
serve/eval/train); opt-out tenants are structurally excluded fromeval/trainwhile keepingserve. - A consent registry is the source of truth for opt-outs and is checked at use time, not assumed at ingestion.
- Every generation writes an append-only lineage record: output → sources (id, version, class) → principal → purpose → policy decisions.
- Lineage stores references and versions, not raw content, and the table grants no UPDATE/DELETE (audit-grade, append-only).
- All paths (ingest, retrieve, output, agent tools) ask one PDP; rules are deny-by-default, versioned, and unit-tested in CI.
- The PDP fails closed (an outage blocks access) and caches hot decisions with a short TTL so rule changes propagate quickly.
- Classification, entitlement, consent, and policy decisions are measurable — you can chart denials, over-blocks, and coverage, not just hope they work.
- A DSAR / "what did the model see about subject X" is answerable by query, demonstrated end to end before launch.
Honest stuff
- Governance can become theater. A catalog nobody trusts and policies nobody tests are worse than nothing — they manufacture false confidence. The PDP earns trust only because its policies are unit-tested in CI and its decisions are recorded; without that, you've built paperwork with extra steps.
- Classification is probabilistic. An LLM/Presidio classifier mislabels; a Confidential doc can slip into the pool, or a benign doc gets over-quarantined (0.4% for Mattrx). Make the failure bounded and reviewable — every asset has a catalog row you can re-evaluate — and put humans on the quarantine queue.
- ACLs go stale. Filter-in-index is fast but a revoked permission lingers until re-index; that's exactly why Mattrx revalidates the returned set live. If you skip the live check to save latency, you've chosen "fast" over "correct" on an authorization boundary — say so out loud.
- The PDP is an availability coupling. Deny-by-default means a PDP outage blocks AI features. That's the correct failure mode for governance, but it makes the PDP a tier-1 dependency you must run like one (sidecar, health checks, cached decisions).
- Purpose limitation starves evals if you're sloppy. Excluding opt-out data from
evalis right, but if your consented corpus isn't representative, your quality metrics drift from reality. Curate a consented eval set deliberately. - Lineage is a data-retention problem of its own. It grows forever and references sensitive assets. Give it a retention policy and access controls, or your audit trail becomes your next compliance finding.
- Centralizing is a migration, not a flag. Replacing fourteen scattered checks with one PDP is real work, and during the cutover both must agree or you get inconsistent denials. Migrate path by path, with the PDP shadow-evaluating before it enforces.
- Deny-by-default annoys people. It will block legitimate access on day one because a purpose tag or group was missing. That friction is the system working; budget for the support load and a fast, audited exception path — not a back door.
- Don't classify what you can delete. The cheapest data to govern is the data you never kept. Before building elaborate controls around a dataset, ask whether the AI feature needs it at all — minimization beats governance.
The closing mental model
Governance is the control plane: classify on the way in, authorize every access by
(principal, data, purpose, context)deny-by-default, and record what happened — so "could this leak?" is a query you can run, not a risk you carry.
Three enforceable habits:
- Govern at the front door. Classify and decide before data is embedded or used. What never enters the context can never leak from it.
- Ask one engine, deny by default. Every path — ingest, retrieve, output, tools — asks the same PDP the same question. If a new code path doesn't ask, it's a hole; make asking the only way to get data.
- Record so you can prove it. Append-only lineage turns "trust us" into "here's the query." If you can't reconstruct what the model saw and why it was allowed, you don't have governance — you have hope.
Continue the series
This is Part 6 of Context Engineering for Enterprise AI. (You are here.) It's the foundation the earlier parts stood on — the data-governance control plane beneath capability.
The full series:
- Part 1: Context Management
- Part 2: The Memory Layer
- Part 3: Multi-Agent Architecture
- Part 4: Enterprise AI Design
- Part 5: Multi-Tenant Patterns
- Part 6: AI & Data Governance (you are here)
Further reading
- Part 2: The Memory Layer — memory isolation and right-to-be-forgotten, the mechanics this part governs but does not repeat.
- Part 4: Enterprise AI Design — injection defense, eval gates, and cost tracing the PDP and lineage complement.
- Part 5: Multi-Tenant Patterns — the tenant predicate that entitlement-aware retrieval stacks the principal predicate on top of.
- RAG with Azure OpenAI, Azure AI Search and C# — the retrieval foundation the classification and ACL filters extend.
- Azure observability and AI for enterprise ASP.NET — the Log Analytics queries behind the coverage and denial diagnostics.
Standing up AI governance and unsure where the control plane should sit — classify at ingestion or retrieval, filter ACLs in the index or live, build a PDP or wire rules per service? Email me at randhir.jassal@gmail.com with how your data is classified today and I''ll send back the boundaries I''d enforce first.
Get the next issue
A short, curated email with the newest posts and questions.