A domain engineer should be able to ask an assistant "which sensitivity level applies to a column that stores an EU IBAN?" and get the answer from the live LakeFormation policy rather than from a wiki page somebody last edited in 2023.
That sentence is easy to write and considerably harder to earn. The interesting part is not that a model can answer the question — it is where the answer comes from, and what stops the model from answering when it should not.
Companion to Fabric + Mesh on AWS. I wrote that sentence at the end of July; that post's § 3 ended with a forward-pointer and no landing page: "the next affordance this ontology deserves is an MCP-exposed catalog tool… It is a natural next step; it is not in the reference implementation yet." This post is the design behind that sentence. The tool is still unbuilt; what follows marks which parts are protocol, which are pattern, and which are neither yet.
The Model Context Protocol moved a long way while those posts were being written. Everything below is checked against revision 2026-07-28, and the revision is named on purpose: a post about a protocol that dates itself is more useful than one that pretends to be timeless.
§ 1 — Why the catalog, and not the warehouse
The reflex when someone says "let the agent query our data" is text-to-SQL against the lake. Part 2 § 7 argued why that is the wrong surface: raw SQL against a governed lake is unsafe for an agentic consumer, and a typed semantic API above it is what makes a question answerable without hallucination.
That names one tier — the semantic layer, where monthly_recurring_revenue means one thing to every consumer. This post is about the other, and the two are easy to swap. The semantic layer answers questions about numbers: what was fraud recall at 30 days last quarter? The catalog answers questions about the data itself: which sensitivity level applies to a column that stores an EU IBAN? Who owns the claims domain? May this role read it?
None of it is queryable from the lake. It is metadata about the datasets rather than data in them — LakeFormation's LF-tags, ownership records, grant expressions, descriptors — four stores behind four APIs in the governance account, reached through the console or Terraform.
A determined engineer can assemble the answer by hand, and nothing here is impossible without an assistant. But that is the wrong counterfactual. Nobody writes a four-API join at 16:40 while adding a column to a table — they copy the tag from the column next to it, or ask in Slack and get somebody's memory of a decision made in March. The tool competes against guessing, not against scripting. And "stores an EU IBAN" is not a lookup key in any case: no record is filed under that phrase, so answering means matching an informal description against the columns already classified — which is § 7's problem, not SQL's.
A catalog question is not a query with tighter permissions on it. It is a different question against a different store — and the answer counts only if the model retrieved it rather than knew it.
That constraint is sharper than it looks. A domain engineer tagging a new column asks which sensitivity level applies to an EU IBAN, and the model answers high — fluently, and correctly: that is exactly how policy_curated.customer.iban is tagged in the reference.
Now the same engineer tags an IBAN column in an analytics extract that is replicated across regions. The sensitivity is still high, so the answer still looks right. But residency records where the data sits, not where the account holder banks — the source table is residency: eu and this copy is residency: global, which changes which grants reach it while leaving the sensitivity untouched. A model reasoning from world knowledge will offer eu anyway, because that is what EU IBAN says, and validation accepts it — eu is a permitted value. The tag set is legal, it is wrong, and nothing downstream will say so.
That is the failure mode — right about the obvious key, quietly wrong about the one carrying the policy. The platform team owns this ontology, and they are not standing behind the domain engineer while the column gets tagged. Retrieving how comparable columns are already classified is what puts them there in effect; asking a model what it knows about IBANs cannot. So the assistant's job here is retrieval and routing; the judgement stays in the policy store.
§ 2 — Tools or Resources — the decision everything else hinges on
MCP exposes two server-side primitives that look interchangeable in a diagram and are not. The specification separates them by who decides. Tools are model-controlled — the language model discovers and invokes them from its own reading of the conversation. Resources are application-driven — the host application decides what to put in context, typically through a picker the user drives.
A catalog is both, and splitting it correctly is the most consequential decision in the design.
The LF-tag ontology is a Resource. From § 3 of the reference: five keys, twenty-one values, small enough to fit on a whiteboard. That is a document, and a host should be able to pin it into context wholesale — the same way an IDE pins an open file — so the model reasons against the full vocabulary instead of discovering it one lookup at a time.
A grant check is a Tool. "May this role read this table?" is a question with arguments, asked mid-reasoning, whose answer the model cannot predict. It has to be invoked.
The costs of getting this backwards are asymmetric. Everything-as-Tools burns turns re-fetching a twenty-one-value vocabulary the model could have held from the start. Everything-as-Resources is worse — the model cannot look anything up on its own, and a host forced to anticipate every dataset a conversation might touch will anticipate wrong.
The split shows up in the wire format, and it is worth seeing the two side by side. The ontology is one fixed document, so it is a Resource with a uri. Datasets are parameterised, so they are ResourceTemplates with a uriTemplate — the protocol uses RFC 6570 URI templates:
// ← resources/list — one fixed document, addressed directly
{
"resultType": "complete",
"ttlMs": 300000,
"cacheScope": "private",
"resources": [
{
"uri": "cordata://ontology/lf-tags",
"name": "lf-tag-ontology",
"title": "LF-tag ontology",
"description": "The governance-owned tag vocabulary. Keys, permitted values, and what each value implies.",
"mimeType": "application/json"
}
]
}
// ← resources/templates/list — a shape, expanded per domain and table
{
"resultType": "complete",
"ttlMs": 300000,
"cacheScope": "private",
"resourceTemplates": [
{
"uriTemplate": "cordata://domain/{domain}/dataset/{table}",
"name": "dataset",
"title": "Dataset descriptor",
"description": "Schema, ownership, LF-tags and contract for one published dataset.",
"mimeType": "application/json"
}
]
}
A URI with no variables in it is not a template — it is a resource that has been filed in the wrong list. The distinction is mechanical, and it is the same one the section has been arguing all along.
Template arguments can be auto-completed through the protocol's completion API, which means a host can offer a domain picker without the server writing a picker.
Diagram read-out. Three tiers, and the boundary that matters is not the one between accounts. Outside the governed accounts sit the host application, which pins the ontology as a Resource, and the language model, which invokes lookups as Tools — the host may well run inside a consumer account, but the model inference usually does not, which is the whole reason § 6 exists. Both paths enter the central governance account — the same one that holds the LakeFormation admin plane in the reference topology — where the caller's token is validated as this server's own audience before anything is read. That validation is the account boundary doing its job: the server holds its own LakeFormation grants rather than borrowing the caller's, which is what § 5 means by refusing passthrough. Where scope falls short the answer is a challenge, not an empty list — an empty one would tell the agent the dataset does not exist, which is a different sentence and a false one (§ 5).
The producer accounts are where the distinction has to be exact. They hold two different things, and the catalog reaches one of them. Column schema lives in the domain's Glue catalog, and get_dataset reads it — that is metadata, and the dotted edge is real traffic. The S3 objects beside it hold the rows, and they carry no inbound edge at all. So the line the design defends is the metadata plane against the data plane, not one account against another: a catalog server that reads a producer's Glue catalog is behaving correctly, and the same server reading a producer's S3 object is the failure § 4 is built to prevent.
Which leaves the decision the section opened with, now with the consequences attached. Resource or Tool is not a tidy way to organise an API surface — it decides who may pull a thing into context: the host, deliberately and in advance, or the model, mid-reasoning and on its own initiative. That single choice then fixes what varies by the caller's scopes (§ 5), what a cache may hold and for how long (§ 7), and which primitive the safety envelope has to be enforced on rather than annotated (§ 4). Get it backwards and none of those three has a stable answer, because the thing being governed keeps changing hands. Everything downstream is a consequence of this split, which is why it comes first.
§ 3 — The tool set, and where its schema comes from
The catalog server exposes four tools. § 1 put three questions to the catalog — who owns the claims domain? which sensitivity level applies to a column that stores an EU IBAN? may this role read it? — and each already has its answer sitting in a store the reference topology holds: ownership in DataZone, schema in the domain's Glue catalog, the permitted vocabulary in the LF-tag ontology, the reach of a role in the LakeFormation grants. The sensitivity question takes two tools rather than one, because retrieving precedent and validating a proposal are different operations — the exchange below shows why.
| Tool | Question it answers | Determinism |
|---|---|---|
list_domains |
What domains exist, and who owns them? | Exact |
get_dataset |
What is this dataset's schema, owner, and tag set? | Exact |
resolve_lf_tag |
Is this tag set valid against the ontology? | Exact |
check_grant_expression |
Would this role's grant expression reach this dataset? | Exact |
Every one is a lookup or an evaluation. None asks a model to decide anything — which is what makes the tool set auditable, and which is the inclusion rule in full: a question earns a tool when its answer already exists as a record in the governance plane. That rule is what generates the exclusions in § 4 as well — rows are data rather than metadata, a per-user grant listing is an access-review artefact with its own approval path, cross-domain composition is a subscription workflow. Those are not gaps in the tool set; they are the same rule, applied.
Most of the schema for these tools does not need inventing. Part 1 § 2 ended on the rule that one Pydantic model is imported by both CI and the executor, because two implementations of the same rules drift. The catalog server becomes its third importer: ownership and the policy contract are the descriptor's own fields, so get_dataset reuses them rather than restating them. Only the column list is declared locally, because the descriptor does not carry one — columns come from the domain's Glue catalog, which is the same plane boundary the § 2 diagram draws.
What follows is a fragment of the catalog server's own source: a module-level declaration of one tool. It is evaluated once, when the server process starts, and the resulting object is what every tools/list response hands back. Nothing here runs per request — the two model_json_schema() calls happen at import, which is why a change to the descriptor model reaches the advertised schema without anyone editing the server:
from pipeline_runtime.descriptor import Contract, Metadata
GET_DATASET = {
"name": "get_dataset",
"title": "Dataset lookup",
"description": "Schema, ownership and policy tags for one published dataset.",
"inputSchema": {
"type": "object",
"properties": {
"domain": {
"type": "string",
"description": "Owning domain, e.g. fraud or claims",
"x-mcp-header": "Domain",
},
"table": {"type": "string"},
},
"required": ["domain", "table"],
"additionalProperties": False,
},
# The governance-owned halves are not redefined here. One model, three
# consumers: CI validation, the executor, and now the catalog server.
"outputSchema": {
"type": "object",
"properties": {
"metadata": Metadata.model_json_schema(), # name, domain, owner
"contract": Contract.model_json_schema(), # freshness_sla, lf_tags
# Columns are the one part the descriptor does not carry: they are
# read from the domain's Glue catalog, so their shape is declared here.
"columns": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"type": {"type": "string"},
"lf_tags": {"type": "object",
"additionalProperties": {"type": "string"}},
},
"required": ["name", "type", "lf_tags"],
"additionalProperties": False,
},
},
},
"required": ["metadata", "contract", "columns"],
"additionalProperties": False,
},
}
One declaration, three consumers. The model that fails a pull request when a descriptor is malformed is the same model that tells an agent what shape to expect. When a field is added, all three move together or none do.
The declaration says what the answer will look like. The handler is what fills it in, and it runs once per tools/call — two reads against the domain's account, joined on the column name:
def get_dataset(domain: str, table: str, caller: Identity) -> dict:
authorize(caller, domain) # § 5 — before either read, not after
# The server runs in the governance account; the catalog it needs lives in
# the domain's. One assumed role per call, session-named for the caller, so
# the producer account's CloudTrail attributes the read to a person.
producer = boto3.Session(**assume_reader_role(domain, caller))
glue = producer.client("glue")
lf = producer.client("lakeformation")
# `<domain>_curated` is the per-domain Glue database the blueprint creates
# (reference § 2: name = "${var.domain_name}_curated"), so the tool takes a
# domain and a table and never asks the caller for a database name.
cols = glue.get_table(DatabaseName=f"{domain}_curated", Name=table)
cols = cols["Table"]["StorageDescriptor"]["Columns"]
tagged = lf.get_resource_lf_tags(
Resource={"TableWithColumns": {
"DatabaseName": f"{domain}_curated", "Name": table,
"ColumnNames": [c["Name"] for c in cols]}},
ShowAssignedLFTags=True,
)
by_column = {
c["Name"]: {t["TagKey"]: t["TagValues"][0] for t in c["LFTags"]}
for c in tagged["LFTagsOnColumns"]
}
return { # validated against outputSchema on the way out
"metadata": metadata_for(domain, table),
"contract": contract_for(domain, table),
"columns": [
{"name": c["Name"], "type": c["Type"],
"lf_tags": by_column.get(c["Name"], {})}
for c in cols
],
}
Nothing in there is clever, which is the point: two AWS calls and a dictionary join. The role assumption is the one line worth pausing on — it is the cross-account hop from § 1 of the reference, and naming the session after the caller is what keeps the producer account's CloudTrail useful. A read attributed to mcp-catalog-server tells an auditor nothing; one attributed to the engineer who asked is the same behaviour record Behaviour-first governance already projects from. Neither call can return a row — get_table returns column definitions and get_resource_lf_tags returns tags, and there is no third call to Athena. The read-only property § 4 insists on is a fact about which APIs the handler is able to reach, visible in the source, rather than a claim made in an annotation.
The protocol is strict about the contract this creates: where an output schema is declared, servers MUST return structured results conforming to it, and clients SHOULD validate against it.
The x-mcp-header annotation on domain is worth a note. A parameter marked this way is mirrored into an Mcp-Param-Domain HTTP header, so a load balancer or WAF can route on it without parsing the request body — which maps directly onto the account-per-domain topology from § 1 of the reference. It carries its own warning, and it is a real one: header values are visible to network intermediaries, so sensitive parameters must never be marked this way. A domain name is fine. A subject identifier is not.
The IBAN question, answered
Back to the sentence that started this. A domain engineer is tagging a new column and asks which sensitivity level applies to one holding an EU IBAN.
The answer does not come from the model's opinion about IBANs. It comes from precedent already in the catalog. § 3 of the reference tags exactly this column, in Terraform:
resource "aws_lakeformation_resource_lf_tags" "iban_column_high" {
database { name = "policy_curated" }
table { name = "customer" }
column { name = "iban" }
lf_tag {
key = "sensitivity"
values = ["high"]
}
}
So the exchange is a retrieval, then a validation:
// → tools/call — find how comparable columns are already classified
{ "name": "get_dataset",
"arguments": { "domain": "policy", "table": "customer" } }
// ← result
{ "resultType": "complete",
"structuredContent": {
"columns": [
{ "name": "iban", "type": "string",
"lf_tags": { "sensitivity": "high", "residency": "eu",
"subject_type": "customer" } }
],
"owner": "policy-domain"
} }
// → tools/call — validate the proposed tag set before it is written
{ "name": "resolve_lf_tag",
"arguments": { "sensitivity": "high", "residency": "eu",
"subject_type": "customer" } }
The second call is not a formality. It runs the same policy.resolve() the executor runs, against the same governance-owned vocabulary, and it fails the same way:
def resolve(declared: dict[str, str]) -> dict[str, str]:
known = ontology() # governance-owned vocabulary
for key, value in declared.items():
if key not in known:
raise UnknownTagKey(key, allowed=sorted(known))
if value not in known[key]:
raise UnknownTagValue(key, value, allowed=known[key])
return declared
An assistant proposing sensitivity: hgh gets UnknownTagValue with the permitted set attached, at the moment of tagging — rather than a table written with a tag nothing grants against. That is the defence-in-depth the original callout claimed, and it is worth being clear about which half does the work: the model reduces search time and the ontology refuses invalid answers, and neither half is sufficient alone.
§ 4 — The safety envelope is enforced, not annotated
The tempting shortcut is declaring the tool set read-only. MCP has a field that looks exactly like it should do the job:
{ "annotations": { "readOnlyHint": true, "destructiveHint": false } }
The schema's own documentation removes any doubt about what that is worth — this is the doc comment on ToolAnnotations in schema/2026-07-28/schema.ts:
NOTE: all properties in
ToolAnnotationsare hints. They are not guaranteed to provide a faithful description of tool behavior (including descriptive properties liketitle). Clients should never make tool use decisions based onToolAnnotationsreceived from untrusted servers.
So readOnlyHint is display metadata. It tells a host how to render a confirmation dialog. It enforces nothing, and a safety envelope that depends on it is not an envelope. Read-only has to hold by construction. The handler has no write path, the database role has no INSERT, and the grants stop at SELECT and DESCRIBE. A server built that way stays read-only no matter what its annotations say.
What the tool set deliberately does not expose matters as much as what it does:
-
No raw rows. The catalog answers questions about datasets, never from them. An agent that can read
customer.ibanvalues has defeated the entire LF-tag model in one call. - No per-user grant listings. "Would this expression reach this dataset" is answerable. "Show me everyone who can read the claims domain" is an access-review artefact with its own approval path, not a tool call.
- No join hints across domains. Cross-domain composition is a subscription workflow in DataZone, and routing around it through a helpful assistant is exactly the failure the workflow exists to prevent.
Every call gets logged into the governance-plane event stream that Behaviour-first governance § 4 already builds its audit projection from. The tool set is not an exception to the governance model — it is another source of behaviour, and it should show up in the same query a supervisor's ninety-day access question runs against.
One further trap, new enough in the specification to be easy to miss. MCP has no protocol-level session, so servers needing state across calls hand back a handle — and the specification's guidance here — non-normative, but unusually direct — is that for authenticated servers "a handle is a name, not a capability", to be re-validated against the caller's authorization on every call. A dataset_id returned by list_domains is not a ticket. If the second call skips that re-check, the first call has quietly become an access-granting operation.
§ 5 — Authorization is the governance plane
This is the part that changed most, and it lands directly on the mesh argument.
Honesty about scope first, because the specification's framing is easy to misread. Protocol Requirements opens by declaring authorization OPTIONAL for MCP implementations, and then says why in the next three lines: HTTP-based transports SHOULD conform, STDIO transports SHOULD NOT — they "retrieve credentials from the environment" instead — and alternative transports MUST follow the best practices of their own protocol.
So OPTIONAL is about which transports the specification covers, not about how much security a server owes its callers. A stdio server is a local subprocess started by the host: there is no browser to redirect, no audience to bind a token to, and the operating system already decided who may run it. An OAuth flow would protect nothing the process boundary does not already protect. Nothing about that concession applies to a catalog server, which is remote, HTTP, and multi-tenant by construction — the SHOULD lands squarely on it.
And once a server is inside that scope, the requirements stop being soft:
- MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC 9728); clients MUST use it for authorization-server discovery.
- Clients MUST implement Resource Indicators (RFC 8707) and send
resourceon both authorization and token requests. - Servers MUST validate that access tokens were issued specifically for them as the intended audience, and MUST NOT accept or transit any other tokens.
That last requirement is the confused-deputy protection stated normatively: token passthrough is prohibited. A catalog server cannot take the token it was handed and replay it against LakeFormation on the caller's behalf. It has to be its own audience, with its own grants.
On AWS that prohibition costs nothing, because there is nothing to pass through to. LakeFormation has never heard of a bearer token. Its grants are written against IAM principals, so an OAuth access token is not a weaker credential there — it is not a credential at all. The identity has to be translated, and that translation is where this section stops being about MCP and starts being about your accounts:
_SAFE = re.compile(r"[^\w+=,.@-]") # STS: alphanumerics and +=,.@- only
def assume_reader_role(domain: str, caller: Identity) -> dict:
# 1. Scope check, against the token this server already validated as its
# own audience. The token does not travel any further than this line.
if f"catalog:{domain}:read" not in caller.scopes:
raise InsufficientScope(required=f"catalog:{domain}:read")
# 2. The server assumes the domain's reader role under its OWN identity.
# The caller is carried as a name and a session tag, never as a
# credential — an OIDC `sub` may contain characters STS rejects.
out = boto3.client("sts").assume_role(
RoleArn=f"arn:aws:iam::{ACCOUNTS[domain]}:role/cordata-catalog-reader",
RoleSessionName=f"mcp-{_SAFE.sub('-', caller.sub)}"[:64],
Tags=[{"Key": "cordata:caller", "Value": caller.sub[:256]}],
DurationSeconds=900,
)["Credentials"]
return {"aws_access_key_id": out["AccessKeyId"],
"aws_secret_access_key": out["SecretAccessKey"],
"aws_session_token": out["SessionToken"]}
assume_role rather than assume_role_with_web_identity is deliberate. Federating the caller's own JWT into AWS would mean forwarding a token this server accepted as its own audience on to a third party — the passthrough the MUST NOT above prohibits. The token stops at the scope check.
So the governance chain has three links, no two of them enforced by the same system, and only one of them living in the MCP server at all:
| Link | Enforced by | Owned by | Decides |
|---|---|---|---|
| Role trust policy | IAM, in the domain's account | Platform team, via the account blueprint | Whether this server may assume the role at all |
| LF-tag grant expression | LakeFormation, governance account | Governance | Which columns come back |
| Scope check | The MCP server's own code | The server | Whether the call is attempted in the first place |
The server holds none of that policy — a scope check and an AssumeRole call is the whole of it, which is the entire reason a compromised catalog server is a contained problem rather than a catastrophic one.
The role's trust policy decides who may assume it at all, and it is platform-owned — part of the same domain-account blueprint as everything else in § 2 of the reference, not something a domain writes by hand:
data "aws_iam_policy_document" "catalog_reader_trust" {
statement {
effect = "Allow"
# sts:TagSession is not optional here — without it the Tags= argument
# in the handler fails, and the caller's identity never reaches CloudTrail.
actions = ["sts:AssumeRole", "sts:TagSession"]
principals {
type = "AWS"
identifiers = [var.catalog_server_role_arn] # governance account, one role
}
condition {
test = "StringEquals"
variable = "sts:ExternalId"
values = [var.catalog_external_id]
}
}
}
One named principal, in one account. Nothing else, in any account, can assume this role. A domain that wants to stop answering catalog questions removes the trust statement, and no coordination with the server is required.
The LF-tag grant on that role decides which columns come back, and it is governance-owned — written once against a tag expression, never against a table name.
And the scope check stays in the MCP server, necessarily — AWS never sees the caller's token, so no IAM policy can condition on what is in it. That check is ordinary application code and can therefore have bugs, which is why the role it guards is scoped to a single domain: a bug there costs one domain's metadata, not every domain's.
The sentence that matters most for a federated mesh sits in the tools chapter, and repeats verbatim for resources/list. The tool set MUST NOT vary per-connection, but it MAY vary "by the authorization presented on the request — for example, returning only the tools the caller's granted scopes permit — since credentials are per-request input, not connection state."
That is account-per-domain expressed in a protocol: a fraud engineer's token surfaces fraud datasets, and the identical server shows a different catalog to the governance function. Not a filtered view rendered by a client — a different response, decided server-side, per request.
And when the caller asks for something outside their scope, the correct answer is not an empty list:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="catalog:claims:read",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
error_description="Read access to the claims domain is required"
An empty result tells an agent "no such dataset", and a competent agent will believe it and tell the user the data does not exist. A scope challenge tells it what to ask for, and the client can run a step-up authorization flow. The specification also asks servers to emit all required scopes in a single challenge rather than one at a time, because incremental challenges force repeated round-trips for one operation.
This maps onto grant expressions better than anything designed for the purpose would have. "You cannot see this" and "this does not exist" are different sentences, and only one of them is true.
§ 6 — DSGVO (the GDPR) — what the design keeps out of scope
Two legal statements, both narrow enough that anyone can check them against the text. Art. 28(3)(a) DSGVO requires a processor to act only on the controller's documented instructions. Art. 5(2) requires the controller to be able to demonstrate compliance. Everything else in this section is architecture rather than law, and I will keep the two apart.
The architectural claim is about scope. § 4's rule — questions about datasets, never from them — means a sensitivity=high tag and a column name cross the boundary and an IBAN never does. That is worth stating plainly, because the reflex assumption about an assistant near a governed lake is the opposite one. An assistant that reaches rows raises the whole processor question; this design's contribution is to keep one surface on the other side of that line rather than to answer it.
What the protocol contributes is evidence. Audience-bound tokens make a call attributable to a named client acting for a named principal rather than to an ambient credential, and prohibited token passthrough stops the chain widening silently to a fourth party. Whether the resulting record is legally required is not mine to say. Where it goes is checkable, and it is worth being precise rather than waving at the earlier post.
Every call in this design leaves an AWS trail before it leaves an MCP one: sts:AssumeRole into the producer account, then glue:GetTable and lakeformation:GetResourceLFTags inside it. Those are CloudTrail events, and the cross-account stitching in Behaviour-first governance § 4 already reads exactly this shape — its own diagram has the AssumeRole into producer hop in it. The session name from the handler is what makes the trail resolve to a person rather than a service. One caveat that post is explicit about and this one should repeat: CloudTrail data events are not on by default, so "no new reporting path" holds only where that Terraform is already applied.
The RoPA projection is a different matter, and the honest answer is that a catalog read does not appear in it. § 2 derives RoPA from OpenLineage RunEvents — jobs moving data — and a tool call is not a job run. That is a gap only if you expected the catalog to be a processing activity. Under the scoping above it is not one, so the absence is consistent rather than missing. The reference runtime's ontology carries subject_type and retention because the pipelines that do move data need them for that projection, not because the catalog is doing anything to a data subject.
Two cautions that are not legal claims at all:
- Organisational, before architectural. A works council will hear "an AI can see the data catalog" as a monitoring question long before an architecture one, so bring the tool inventory and the audit projection to that meeting rather than the protocol diagram.
-
Aggregation, not any single answer. One dataset name is unremarkable; the complete list of them is a map of how the business is organised — which domains exist, what they are called, where the regulated ones sit. An agent that can enumerate everything holds that map, and no individual call is the moment it was handed over. That is the argument for scoping
list_domainsby authorization exactly as § 5 permits: domain names are not harmless in bulk merely because each one is harmless alone.
§ 7 — Retrieval over a closed set
§ 3's IBAN exchange skipped a step. The handler turns a domain and a table into policy_curated.customer on its own — but how did the assistant arrive at domain: policy, table: customer to begin with?
The naive answer is to let the model guess a table name. The better one exploits a property of this problem — the vocabulary is closed and tiny. Five keys, twenty-one values, and dataset and column names that are enumerable.
For a closed set, embedding the vocabulary and retrieving the nearest entries beats asking a model to produce a value from memory, for a reason that has nothing to do with model quality: retrieval cannot return a value that is not in the set, and generation always can. A model asked to invent a sensitivity level will invent pii or restricted — plausible, absent from the ontology, and rejected by policy.resolve() one step later. Retrieval over the twenty-one permitted values cannot produce that failure at all.
That disposes of invalid values. It does not touch the harder case from § 1, where the model proposes residency: eu for a globally replicated copy — a permitted value, accepted by resolve(), and wrong anyway. No vocabulary check catches that one, because the vocabulary was never violated. What catches it is a second retrieval against a different corpus: the classifications already applied to comparable columns. Asking get_dataset what the other columns in that same extract carry answers from precedent, and the reply stops depending on what the phrase EU IBAN suggests.
So the shape is: embed the ontology values and the catalog's column descriptions once, retrieve candidates for "stores an EU IBAN", and let the tools do the rest. Generation belongs where the answer is genuinely open — a description, a summary of why a dataset exists — and nowhere near a policy value.
That word once is where the cost sits. An embedding index is a snapshot; the catalog it describes is not. Datasets get published, columns get reclassified, and a retrieval that was correct on Monday starts answering from a world that has moved — silently, because a stale index returns confident nearest matches rather than errors.
The protocol has an answer for the part of this it can see. List and read results alike carry ttlMs and cacheScope, so a client knows how long the answer it just received may be trusted:
{
"resultType": "complete",
"resources": [ /* … */ ],
"ttlMs": 300000,
"cacheScope": "private" // never "public": this list varies by scope
}
Nothing about this is stored in the catalog. ttlMs is a property of the response, not of the asset — the server picks a number as it answers, from what it knows about how quickly that particular answer goes stale. The LF-tag ontology changes by governance pull request, so it can afford minutes. A dataset list changes whenever any domain publishes, so it can afford less. A single resources/read of one descriptor sits somewhere between. None of that is catalog metadata; it is a policy the server holds, and the only thing the client is told is the number.
The server's own embedding index is outside all of it. No client-side TTL refreshes something the server built for itself — that refresh is triggered by the same events described below, not by anything in a response.
private is not a preference here. It follows from the choice made in § 5 to let the lists vary by the caller's granted scopes, because the specification defines public as a response any client or intermediary may cache and serve across authorization contexts. Combine the two and a shared cache in front of the catalog becomes a way to hand one caller's view to another — the fraud engineer's dataset list served from a proxy to somebody with no fraud scope at all. A server that returned an identical list to everyone could say public honestly. This one cannot, and the field is where that consequence becomes visible to the client.
Five minutes is too long whenever a tag write lands inside the TTL window. A governance PR retags policy_curated.customer.iban from sensitivity: confidential to high at 14:03; a client that listed at 14:01 keeps serving the old classification until 14:06, and every retrieval in between answers against a grant expression that no longer holds. The protocol's answer is subscriptions: the client opens a long-lived subscriptions/listen stream naming the URIs it cares about in notifications.resourceSubscriptions, the server replies with notifications/subscriptions/acknowledged, and thereafter pushes notifications/resources/updated whenever a watched resource changes. A server declares resources: { subscribe: true } to offer it at all.
Writing that sentence is considerably easier than deploying it. What it actually costs, on AWS:
- A compute target that stays up. Not Lambda. ECS, Fargate or App Runner, with the scaling, health checks and monitoring that a long-running service needs and a function does not.
-
idle_timeout.timeout_secondsraised on the ALB. The default is 60 seconds; a quiet subscription is closed by your own load balancer before it reports anything. - A heartbeat, if raising that timeout is not enough or not permitted.
- Egress proxies you do not control. Any enterprise network may buffer or terminate long-lived responses whatever you configure at either end.
- Client-side reconnect, backoff and re-subscribe — plus reconciling what changed while disconnected, which is the part that gets discovered in production.
So subscriptions are an optimisation, and for a catalog it is worth asking whether it is ever the right one. The ontology looks like the obvious candidate because every answer depends on it — but small and rarely changed are exactly the properties that make re-reading it cheap. One document, a short ttlMs, and the exposure is bounded by the window. Dataset descriptors have the opposite shape: they genuinely do change, but watching them costs a URI per table and a held stream per connected host. Neither case is a comfortable fit.
Which leaves the unglamorous default doing most of the work, including through a proxy you cannot change: a short ttlMs, plus a resources/read of the one descriptor immediately before acting on its policy value — the retrieve-then-validate shape from § 3, with the read doing what the stale index cannot. Subscriptions pay off where a resource changes often and is watched by few. A catalog is usually the other way round. Reclassification propagating without touching pipelines was already a property of the ontology in Part 1 § 6; this extends the same property to the agent's view of it.
§ 8 — What this design does not solve
Three gaps, stated rather than buried.
A catalog cannot describe what nobody registered. Asked about a dataset a domain built last week and never published, an agent correctly reports that it does not exist — indistinguishable, to the person asking, from the tool being broken. Coverage is a governance property, not a protocol one.
Annotations remain untrusted in aggregate. § 4 covers a single server. A host aggregating several inherits all their trust assumptions, and cross-server tool-name collisions are explicitly the client's problem to disambiguate. A catalog tool that is safe alone is not automatically safe beside an arbitrary third-party server in the same context window.
Half of this does not port. Everything the protocol contributes is cloud-agnostic — Tools versus Resources, output schemas, scope challenges and cache scoping hold wherever you run them — but the identity chain in § 5 is built out of AssumeRole, LF-tag grant expressions and account-per-domain, none of which exist elsewhere. On another cloud the question how does a validated caller become a credential the catalog can be read with? has to be answered again from first principles, and while the shape of that answer transfers, none of the code does.
What gets built next
Every protocol claim above is checked against the specification; none of it has served a request yet. That is worth stating plainly, and worth not leaving there.
The next piece of work is a running server in its own repository, built on the descriptor model the pipeline runtime already publishes — so that § 3's argument about one model and three consumers becomes something you can check out and run rather than something I have asserted. Until then this post is a design, and it is not meant to stay one.
Where this fits
The Governance pillar claimed governance artefacts should be byproducts of behaviour; Fabric + Mesh on AWS built the account topology and the tag ontology; Behaviour-first governance turned the emitted events into the projections an auditor asks for; the pipeline pair made a pipeline a declaration that emits its own evidence.
This post adds the read path, and the arc closes somewhere I did not expect when the first one was written. The ontology those posts spent so long keeping small, explicit and machine-checkable is what makes agentic access tractable — not because the agents are clever, but because a twenty-one-value vocabulary with a validator behind it is a search problem rather than a judgement problem. Invert it and the claim falls over: three hundred values and no validator, and no amount of protocol rescues any of this.
If you have put an agent in front of a governed catalog — or think doing so is a worse idea than I have made it sound, and can say where it breaks — I would genuinely like to hear the counter-argument. The door is open at cordata.tech/contact. Related reading: the Fabric + Mesh on AWS reference for the LF-tag ontology every tool here reads from; part 1 — a pipeline is a descriptor, not a program for the descriptor model the output schemas derive from, and part 2 for the semantic-layer tier this one sits beside; and Behaviour-first governance in practice for the audit projection every tool call lands in.
Originally published at cordata.tech.

Top comments (0)