2.6 Service Catalog: Teams, Callers, Targets & Policy¶
The catalog is the single place that says which targets exist (agents, MCP servers, models,
legacy APIs), which team owns each one, which callers may call it, through which gateway, and who
pays for the calls. Terraform, the entry point, the invoker grants and esmeralda verify all
derive from it, so adding a caller or a target is a catalog change reviewed by the owning team.
Two principles shape it:
- Where the data lives is pluggable; enforcement is compiled. The catalog can be assembled
from several sources (Git files, Agent Registry, project labels, a CMDB).
esmeralda catalog resolvemerges and validates them, and compiles the result into what the platform enforces. Neither the runtime nor Terraform ever reads a source directly. - Contracts, not implementations. Every document follows
contracts/catalog.schema.json(schema: esmeralda.catalog, integerschema_version, additive changes only). Any source that produces documents of that shape can feed the resolver.
1. Data model¶
| Section | Holds | Natural source in a real organization |
|---|---|---|
| teams | cost centres, country, external flag |
project labels, finance, CMDB |
| callers (non-workload identities) | identity type + id → team | IdP groups, Apigee apps, federation config |
| targets | kind, owner_team, deploy (unit, or managed: false + endpoint), serving, gateway, callers (tools, quota, product, allowed cost centres, gateway per caller), user_credential, models, observability |
Agent Registry, API Hub, Backstage (inventory); Git (policy) |
| policy | attribution.unmapped_caller (deny / flag), attribution.declared_mismatch (reject / bill_own_team), defaults, registered usage dimensions |
platform and security |
Agents call as themselves: a target of kind a2a or adk can be listed directly under another
target's callers. Every other identity is declared under callers with its team and an
identity of type service_account, group, apigee_app, federated (OIDC issuer + subject)
or spiffe. When the platform creates the identity, the identity points at a unit output
(ref: layer-3-security:<output>) rather than a pasted value.
The calling team pays. A caller's grant may list the cost_centers it is allowed to declare;
they must belong to that caller's team.
An example target:
schema: esmeralda.catalog
schema_version: 1
name: ai-coe-mortgage-specialist
kind: a2a
owner_team: ai-coe
deploy: { unit: agents/ai-coe-mortgage-specialist }
serving: { token: access, probe: a2a }
models: [gemini-flash]
callers:
cx-mortgage-orchestrator: { cost_centers: [cc-cx-br, cc-cx-mx] }
test-vm: {}
observability:
usage_sources: [gateway, workload]
framework: adk
dimensions: [loan_product]
slo: { latency_p95_ms: 10000, availability: 0.995 }
2. Folder layout (the default files source)¶
catalog/
├── README.md
├── sources.yaml # which sources the resolver merges, in order
├── teams.yaml # team registry
├── policy.yaml # attribution rules, defaults, registered dimensions
├── teams/
│ ├── ai-coe/{team.yaml, agents/…, mcp/…}
│ ├── cx/{agents/…}
│ └── platform/{callers.yaml, models/…}
├── external/
│ ├── partners/… # Apigee apps -> partner teams
│ ├── clouds/… # federated (issuer, subject) callers
│ └── legacy/… # managed: false endpoints (brownfield)
├── environments/{dev.yaml, prd.yaml} # overrides only
└── .resolved/{dev.json, prd.json} # generated and committed: what Terraform reads
Ownership comes from CODEOWNERS: each team approves its own teams/<team>/ folder, while platform
and security approve external/, policy.yaml and sources.yaml (catalog/README.md has a
snippet). Entries marked example: true (the samples under external/) are resolved and shown,
but never compiled into enforcement.
3. Resolve, compile, inspect¶
uv run esmeralda catalog resolve --env dev # full resolved catalog (JSON)
uv run esmeralda catalog resolve --env dev --format hcl # the Terraform catalog map, as HCL
uv run esmeralda catalog resolve --env all --write # regenerate catalog/.resolved/*.json
uv run esmeralda catalog resolve --env all --check # exit 1 when a generated file is stale
uv run esmeralda catalog show --env dev # who can call what, via which gateway, who pays
uv run esmeralda catalog show --env dev --caller test-vm # everything one caller can call
flowchart LR
S["sources.yaml (in order)"] --> M["merge"]
M --> O["environments/<env>.yaml overrides"]
O --> D["policy defaults"]
D --> V["validate: schema + references"]
V --> R[".resolved/<env>.json"]
R --> T["env.yaml catalog: entry point, direct backends, invokers, verify"]
R --> G["env.yaml catalog_iap_egress: Agent Gateway per-target lists"]
R --> X["env.yaml catalog_external_targets: registry entries of managed: false targets"]
- Merge. Sources are merged in the order
sources.yamllists them. In thefilessource, an entry or a policy leaf defined twice is an error. A later source may add entries or fill fields an earlier source left unset; giving a different value for a field already set is a conflict. - Override.
environments/<env>.yamlis applied last: maps merge, lists replace, andnulldeletes a key (for example,callers: { test-vm: null }removes the test VM in one environment). - Validate. The result is checked against the schema, then the references: unknown teams, callers, models or dimensions, duplicate names or units, and cost centres that a caller's team doesn't own all fail the resolve.
-
Compile. Deployed targets (kind
mcp,a2aoradkwith adeploy.unit) become thecatalogmap incatalog/.resolved/<env>.json:kind,unit,token,probe, an optionaltimeout, and optionalcallers(agents, which getrun.invoker/aiplatform.useras their Agent Identity principal, andservice_accountcallers, which get them as their service account). Eachinfrastructure/live/<env>/env.yamldecodes it:catalog_sources = "catalog/sources.yaml" catalog = lookup(jsondecode(file("${get_repo_root()}/catalog/.resolved/<env>.json")), "catalog")The generated files are committed with each catalog change, and
make test-guardsfails when they are stale. -
Compile the Agent Gateway lists. The same callers also become
iap_egressin the same file: the per-targetroles/iap.egressormember lists the Layer 5iap-egressunit writes on each Agent Registry entry (agentsfora2atargets,mcp_serversformcptargets). Members are references, never pasted values: an agent caller contributes its Agent Identity principal (agents/<name>:agent_identity_principal), which is also the identity it calls out as, and aservice_accountcaller contributes itsref.env.yamldecodes the lists ascatalog_iap_egress, and theiap-egressunit maps each reference to the matching unit output (identity_refs). A reference with no mapping fails the plan."iap_egress": { "agents": { "ai-coe-mortgage-specialist": ["agents/cx-mortgage-orchestrator:agent_identity_principal", "layer-3-security:test_vm_sa_email"] }, "mcp_servers": { "legacy-dms": ["agents/ai-coe-mortgage-specialist:agent_identity_principal", "layer-3-security:test_vm_sa_email"] } }One
callersentry therefore drives both the Cloud Run / Agent Runtime IAM grants and the gateway list, so the two can't disagree. A new agent caller also needs itsdependencyblock and itsidentity_refsentry in theiap-egressunit (and in each MCP service unit, which grants itrun.invokeras its principal);make test-guardschecks that every reference has one and that each list names exactly the target's callers.A caller that reaches its targets directly, not through the Agent Gateway, is declared with
agent_gateway: false. In dev, the Apigee partner-mcp proxy (apigee-partner-mcp) calls the MCP servers it exposes with its own ID token: it keepsrun.invokeron them but is left out of their gateway lists, since Apigee never egresses through the Agent Gateway. A gateway-listed caller whose service account comes from a unit theiap-egressunit doesn't map (anything other thanlayer-3-security) fails the resolve with a hint to set the flag.No shared identity is added: agents call out with their own Agent Identity, so each list names only the target's callers. (The resolver still accepts a generic
policy.agent_gateway.shared_workload_identityblock, which adds one shared member to targets with an agent caller; this repository doesn't use it.) -
Register the targets Esmeralda doesn't deploy. A non-example target with
deploy.managed: false(a legacy API, an agent on another cloud, an external gateway such as an Apigee model gateway) compiles intoexternal_targets: one Agent Registry entry per target, which the Layer 5iap-egressunit creates (env.yamldecodes it ascatalog_external_targets). Its callers compile intoiap_egresslike any other target, so only they can reach it through the Agent Gateway:kindRegistry entry iap_egressgrouphttpendpoint ( HTTP_JSON, no spec)endpointsmodelon a non-Google hostendpoint endpointsmcpMCP server ( JSONRPC, no tool spec)mcp_serversa2aagent, with an A2A card generated from the catalog entry agents# catalog/environments/dev.yaml (overrides.targets): the synthetic target dev verifies with external-probe: kind: http owner_team: platform deploy: { managed: false, endpoint: "https://external-probe.{domain}" } callers: ai-coe-mortgage-specialist: {}- Hostnames. The Agent Registry matches exact hostnames (no wildcards), so
endpointis an exacthttps://URL. Environment-specific parts are placeholders theiap-egressunit substitutes from layer outputs instead of literals:{domain}(the entry point domain, only as<label>.{domain}) and{region}. Two targets can't share a host, and a host can't be the<name>.{domain}of a deployed target. Hosts under a private domain other than the entry point domain also need DNS peering and CA trust in the Agent Connectivity Template (not compiled). - Google APIs. A target on
*.googleapis.com(such asgemini-flashon Vertex AI) is a layer-4 system endpoint, open to every agent project: it gets no entry of its own, andesmeralda catalog showmarks its callers as not enforced at the gateway. - No callers, no access. A target whose callers are all unenforced (groups, federated or Apigee app identities) or absent is still registered, with an empty policy. Removing the target deletes its entry, so the gateway denies the host again; removing a caller rewrites the entry's policy without it.
- Callee authentication is the target's business: the Agent Gateway neither adds nor swaps
credentials.
serving.tokenrecords what agents send (id: a Google ID token for the endpoint, as the Apigee model gateway, Cloud Run or API Gateway expect;access; or omitted). - Examples (
example: true, such asexternal/legacy/example-core-banking.yaml) stay illustrative: they are never registered. - Verification.
esmeralda verify(stepregistry-egress) reads every registry entry and its IAP policy and fails unless the registry root holds no binding, each system endpoint entry lists exactly the layer-4 members, each catalog entry lists exactly its compiled callers, each external target is registered with its exact URL, and any other entry has no policy. In dev, an allowed call from the specialist toexternal-probe.<domain>reaches the entry point and gets its no-route404; any other agent gets the gateway's403.
The registry root never holds a
roles/iap.egressorbinding: a root binding would be inherited by every external entry (see 2.1 §3.4). - Hostnames. The Agent Registry matches exact hostnames (no wildcards), so
-
Compile verified attribution. The same callers compile into an
attributionblock: theprincipal_teamsrows (each verified principal, its catalog caller and the team that pays), the shared identities, and per-target allowlists for the in-process caller guard on MCP servers (ESMERALDA_CALLER_ALLOWLIST).policy.attribution.unmapped_caller(deny, orflagin the environments listed inflag_environments) anddeclared_mismatch(rejectorbill_own_team) decide what the guard does with unknown callers and foreign cost centres. The compile fails if a principal that gets a grant has no team, or if one principal maps to two teams. See 5.3 §5.
Later phases compile the same resolved catalog into Apigee products and allowed_tools, Agent
Gateway rules beyond caller lists and observability configuration.
4. Swapping or adding sources¶
catalog/sources.yaml lists the sources:
schema: esmeralda.catalog
schema_version: 1
sources:
- { name: catalog-files, type: files, paths: [teams.yaml, policy.yaml, teams, external] }
- { name: agent-registry, type: agent_registry, enabled: false } # extension point
- { name: project-labels, type: project_labels, enabled: false } # extension point
environments_dir: environments
resolved_dir: .resolved
| Organization | Typical setup |
|---|---|
| Greenfield / demo | files only (the default) |
| Inventory already in Agent Registry or API Hub | agent_registry for targets and endpoints; files keeps policy, callers and grants |
| Teams and cost centres in project labels or finance systems | project_labels fills owner_team and team cost_centers |
| Backstage, a CMDB or another catalog | A custom adapter |
agent_registry and project_labels are registered extension points. Enabling one today fails
with a clear "not implemented" error. A custom adapter is a small Python class that returns entries
of the schema's shape:
from esmeralda.catalog import Layer, Source, register_source
@register_source("backstage")
class BackstageSource(Source):
def load(self) -> Layer:
layer = Layer(source=self.name)
for component in fetch_components(self.spec.get("options", {})): # your client
layer.targets[component.name] = {
"kind": "http",
"owner_team": component.owner,
"deploy": {"managed": False, "endpoint": component.url},
}
return layer
Then add - { name: backstage, type: backstage, options: {...} } to sources.yaml. The merge,
validation and compile steps stay the same, so enforcement doesn't depend on where the data lives.