How a provider service contributes to Patch: the capability-document schema this service owns, and the three producers that serve it — a fixture file, the capability-provider HTTP API, and the CapabilityBinding CRD.
A capability document is how a provider service's contribution
reaches Patch: its tool endpoint(s), the reviewed tool allow-list, and
its knowledge sources. In the platform, these documents are produced by
the service catalog — a provider registers agent capabilities with
its service, and entitlement decides which projects receive them. The
document schema itself is owned and published by this service
(internal/capability), so the catalog conforms to Patch's contract
(never the reverse), and any other producer — a fixture file in dev, a
different control plane — can feed it the same way. Given a project's
documents, composition produces:
- Knowledge — each document's knowledge sources fetched over HTTP (short timeout, per-source byte cap) and rendered under a provenance header, appended to the system prompt.
- Tools — one MCP client per
tools.mcpServers[]entry (officialmodelcontextprotocol/go-sdk, Streamable HTTP transport), exposing only thetoolSelector.includetools, namespaced<server>__<tool>(sanitized[a-zA-Z0-9_-], first-wins on collision). The allow-list is enforced client-side too. MCP clients are opened per task, given a 5s connect timeout, and closed at the terminal state.
Document shape (JSON; the KRM-style envelope — apiVersion/kind/
metadata/spec — carries provenance from producers like the catalog's
AgentBinding projection; the parser ignores unknown fields and rejects
invalid documents with clear errors):
A skill is a provider-published, reviewed procedure — the middle rung between knowledge (facts the assistant reads) and tools (endpoints it calls): "run this tool, interpret these fields, check X before recommending Y."
Skills use progressive disclosure: only each skill's name and
one-line description enter the system prompt (under "Available
skills"), so a provider can publish many skills at near-zero prompt
cost. When a request matches, the model calls the built-in
load_skill tool, which fetches the body from source (5s
timeout, 64KiB cap, same degrade-gracefully posture as knowledge) and
returns it framed with provenance.
Security posture: a skill is provider content the model may follow —
which is exactly why it goes through the platform's review gate
(catalog-published, versioned configurations) before any customer's
assistant sees it. A skill never grants privileges: it can only direct
the model toward tools that are independently on the enforced
allow-list, and the platform prompt scopes it to that provider's
services. Loading a skill is not a provider tool invocation — no
tool-invocations billing event fires; the tokens it adds are billed
as input like the rest of the prompt. Executable skill bundles
(scripts) are deliberately unsupported.
Every project's composition carries a set of tools that belong to no provider.
They are un-namespaced — resources_list, not <service>__resources_list —
which is the same convention the other built-ins use (load_skill,
memory_remember, memory_forget) and is what makes them impossible for a
provider to shadow: every provider tool name carries a <server>__ prefix.
| Tool | What it answers |
|---|---|
resources_list(group, version, kind, namespace?) |
Every resource of one kind in the project, with what the platform is reporting about each. Kinds held in a namespace default to default. |
resources_get(group, version, kind, name, namespace?) |
One resource as an editable manifest, with the platform's own bookkeeping removed, plus its conditions alongside. |
schema_get(group, version, kind, path?) |
What a kind's fields are and which are required, from the project's own published description. path narrows a large kind to one part. |
locations_list(service) |
Where a named service is offered to this project, joined from ServiceAvailability (services.miloapis.com/v1alpha1) to the Location (locations.miloapis.com/v1alpha1) it names, with topology and readiness. |
quota_get(service?) |
Limit, used and available per resource type, from AllowanceBucket (quota.miloapis.com/v1alpha1, namespace milo-system, label quota.miloapis.com/consumer-kind=Project), converted into the unit ResourceRegistration publishes. |
Three more tools make up the change path, described below.
Three tools. Only one of them changes anything.
Patch composes them only when a plan-token key is available (PLAN_TOKEN_KEY,
or one generated for the process). A service that cannot check a token must not
issue one.
| Tool | What it does |
|---|---|
resources_validate(manifests[]) |
Asks the platform to judge each manifest and keeps nothing. Returns the field path behind each rejection, whether the resource already exists, and what applying would change. Writes nothing. |
resources_plan(manifests[]) |
Validates everything, decides create or update per resource, orders them so a manifest referring to another goes after it, reports the differences, and returns canonical manifests with a plan token. Writes nothing. |
resources_apply(manifests[], planToken) |
Re-derives the token from what it was handed and refuses any mismatch. Then dry-runs once more and applies in plan order. The only tool here that writes. |
The token is an HMAC-SHA256 over:
- the canonical JSON of every manifest, in plan order
- the project
- the resource version of each object the plan saw
- the expiry, 15 minutes out
Anything that moves fails to re-derive: a manifest edited after the plan (one character is enough), a reordered list, a token minted in another project, or a resource someone else changed in between. Apply refuses rather than writing something nobody agreed to. Every refusal says what happened, that nothing changed, and to plan again.
The token proves the change is the one shown. It cannot prove the person agreed.
That is a human step, required by Patch's fixed operating rules: nothing the
assistant reads in a tool result, a provider document, or a resource's status
counts as the person's answer. See internal/plantoken.
A manifest whose spec carries a *Ref or *Refs field naming another manifest
in the batch is applied after it. A kind on the reference is honored when
present. A manifest that refers to nothing else keeps the order you gave it.
Order is part of what the token covers, so a reordered list is refused.
Composed.Mutating lists the composed tools that can change something:
- provider tools flagged in a capability document's
mcpServers[].mutating resources_planandresources_apply
resources_plan is listed even though it writes nothing, because it alone
authorizes a write. An operator asking what this project's assistant can change
wants both answers. Each tool's trace span carries the same value as
tool.mutating.
They act as the caller. Composition binds the platform client to the
caller's own bearer token and the turn's project, once, and hands the tools a
view that carries no other identity (internal/projectapi). The service holds
no credential for a customer's project, so a tool call can read nothing the
person could not read themselves. With no caller credential or no project the
tools are not composed at all — there is no fallback to reading as the
service.
The project is never an argument. No input schema has a project field and no
handler looks for one. The project comes from the request a SubjectAccessReview
already approved, for the same reason X-Datum-Project does: an argument naming
a project would be steerable by anything the model reads.
locations_list refuses loudly when the project does not serve
ServiceAvailability or Location, naming the missing kind and saying the
person did nothing wrong. It never degrades to an empty list. An empty list is a
real answer — the service is offered nowhere this project may use — and
returning it when nothing actually looked would tell a customer to wait for a
location that is already there. The two call for opposite actions.
quota_get behaves the same way: a project that does not serve the allowance
kinds is told the answer is unknown, not that there is no limit.
A base tool fires no tool-invocations billing event, the same as
load_skill and the memory tools. The tool event names the provider service
that did the work (see Metering), and there is no
provider here — the work is the platform reading the customer's own project as
the customer. Billing it to a provider would attribute it to the wrong party;
billing it to Patch would make reading your own project cost you twice, since
the tokens it adds are already billed as input. A provider tool that happens to
do the same read still meters, because that provider ran it.
Capability documents reach the assistant through the Source seam
(internal/capability) — the stable interface between the assistant and
wherever documents come from:
// Source yields the capability documents entitled to a project.
type Source interface {
Documents(ctx context.Context, projectName string) ([]CapabilityDocument, error)
}Three implementations ship, selected by CAPABILITY_SOURCE (see
configuration.md); exactly one is built:
- Fixture source (
CAPABILITY_SOURCE=fixture,CAPABILITY_DOCS_FIXTURE) — a local JSON file (bare array or a{"items": […]}List). Good for local dev and e2e. - HTTP source (
CAPABILITY_SOURCE=http,CAPABILITY_PROVIDER_URL) — the capability-provider API below. Documents are fetched per conversation (no cache in v0). - CRD source (
CAPABILITY_SOURCE=crd) — a cached per-project LIST ofCapabilityBindingobjects from that project's control plane. The production source.
The schema below is the wire contract for the fixture file, the HTTP response
body, and — field for field — a CapabilityBinding's spec. The assistant
owns this schema — a capability provider (the control-plane adapter, the
catalog's projection controller) produces documents in this shape; if the shape
changes, it changes here first.
GET {CAPABILITY_PROVIDER_URL}/projects/{projectName}/capability-documents
Accept: application/json
{projectName}is path-escaped; it is the caller's authenticated project. The provider returns exactly the documents that project is entitled to (server-side scoping — the assistant does not filter).- 200 with a JSON body (see schema) is the only success. The body is
either a bare array of documents or a
{"items": […]}List. - Degradation contract: any transport error, a non-2xx status, an unreadable body, or a malformed root is logged and treated as no capabilities (empty set, chat proceeds with built-ins only) — a provider outage never fails a chat. Individual documents that fail validation are skipped with a warning; the valid ones still apply. Fetches use a 5s timeout.
Derived from the Go types in internal/capability/document.go. Unknown
fields are ignored (forward-compatible); required fields are marked.
configurationVersion is the provider's own config revision, distinct
from this document schema version (v1).
{
"apiVersion": "services.miloapis.com/v1alpha1", // optional, provenance
"kind": "AgentBinding", // optional, provenance
"metadata": { // optional
"name": "string",
"namespace": "string"
},
"spec": { // REQUIRED
"serviceRef": { "name": "string" }, // REQUIRED, name REQUIRED
"serviceName": "string", // REQUIRED (tool-invocation meter dimension)
"serviceAgentRef": { "name": "string" }, // REQUIRED, name REQUIRED
"configurationVersion": "string", // REQUIRED (provider config revision)
"reportingProject": "string", // optional — the provider's OWN project,
// where its team reviews capability-gap
// reports (see capability-gap-reporting-design.md).
// Distinct from metadata.namespace, which is the
// CONSUMER project this document was entitled to.
// Omitted => gap reporting unavailable for this service.
"knowledge": { // optional
"sources": [{
"type": "LLMDocs | Runbook | Markdown", // REQUIRED, must be one of the enum
"title": "string", // optional
"url": "string" // REQUIRED
}],
"concepts": [{
"gvk": { "group": "string", "kind": "string" },
"summary": "string"
}]
},
"tools": { // optional
"mcpServers": [{
"name": "string", // REQUIRED
"endpoint": "string", // REQUIRED (Streamable HTTP MCP URL)
"toolSelector": { "include": ["string"] }, // client-side allow-list
"mutating": ["string"] // optional; tools flagged mutating
}]
},
"authority": { // optional
"reads": [{ "gvk": { "group": "string", "kind": "string" } }],
"maxTaskDurationSeconds": 0 // optional int
}
},
"status": { // optional, ignored by the assistant
"conditions": [{ "type": "string", "status": "string", "reason": "string", "message": "string" }]
}
}{
"items": [
{
"apiVersion": "services.miloapis.com/v1alpha1",
"kind": "AgentBinding",
"metadata": { "name": "streamco-binding", "namespace": "demo-project" },
"spec": {
"serviceRef": { "name": "streamco" },
"serviceName": "streaming.streamco.example",
"serviceAgentRef": { "name": "streamco-agent" },
"configurationVersion": "v1",
"reportingProject": "streamco-platform",
"knowledge": {
"sources": [
{ "type": "LLMDocs", "title": "Overview", "url": "http://127.0.0.1:7810/llms-full.txt" }
],
"concepts": [
{ "gvk": { "group": "streaming.streamco.example", "kind": "Stream" }, "summary": "A live stream" }
]
},
"tools": {
"mcpServers": [
{
"name": "streamco",
"endpoint": "http://127.0.0.1:7810/mcp",
"toolSelector": { "include": ["streams_list", "streams_get", "pipeline_diagnose"] },
"mutating": []
}
]
},
"authority": {
"reads": [{ "gvk": { "group": "streaming.streamco.example", "kind": "*" } }],
"maxTaskDurationSeconds": 60
}
},
"status": { "conditions": [{ "type": "Ready", "status": "True" }] }
}
]
}The catalog-side capability-provider adapter projects AgentBinding
resources into this shape (rewriting MCP endpoints to the gateway
MCPRoute URL). Because the assistant owns the schema, the adapter is
written against this contract, not the other way around.
In production the documents above are not fetched from a file or an HTTP
endpoint. They are read from CapabilityBinding objects
(capabilities.assistant.miloapis.com/v1alpha1) living in each project's own
Milo control plane. This is not a new contract — it is the same schema,
expressed as a Kubernetes resource, so the assistant still owns it and producers
still conform. The Go types are pkg/apis/capabilities/v1alpha1; the manifest is
config/crd/capabilitybindings.yaml.
The kind is scope: Cluster. A Milo project is a virtual control plane — one
apiserver partitioned by an etcd key prefix — not a namespace in the cluster the
assistant runs in. A project plane has its own ordinary namespace set
(milo-system, default) and nothing creates a namespace named after the
project, so a namespaced kind would have forced every producer to invent a
namespace convention that means nothing and that the storage layer never checks.
An assistant LISTing /namespaces/<project>/capabilitybindings against a project
plane would get a well-formed, permanently empty list, and every project would
compose as though it had bought nothing.
The consequence is that a binding carries no project handle at all, and it
does not need one: the objects acme is entitled to are the objects reachable
through acme's control-plane path, and another tenant's bindings are not
filtered out of the response — they are not in the keyspace being read. The
ScopeDocuments namespace check that guards the fixture and HTTP sources is a
no-op here, and that is a strengthening, not a hole: a string comparison against
a producer's convention has been replaced by a path the storage layer enforces.
Field for field, and byte-identical in the JSON tags:
Document (spec) |
CapabilityBindingSpec |
|
|---|---|---|
serviceRef, serviceName, serviceAgentRef, configurationVersion |
same names | required, mirroring Validate() |
knowledge, tools, skills, authority, reportingProject |
same names | optional |
The source marshals the KRM object's spec and feeds the bytes straight through
the same ParseDocuments path the other two sources use. That is what makes one
schema serve three producers rather than three parsers serving one schema — and
it is also the hazard: a JSON tag that drifts between
pkg/apis/capabilities/v1alpha1/types.go and
internal/capability/document.go does not fail a build, it silently drops a
field from every project's prompt. Change both or neither.
Required-ness is mirrored into the OpenAPI schema, which moves the structural
half of validation to admission: a kubectl apply of a binding missing
spec.serviceName fails immediately, against the person who typed it. The
runtime Validate() stays — it still guards the fixture path, and a newer
assistant may refuse what an older CRD admitted.
A complete example, the CRD form of the StreamCo fixture, is
config/overlays/dev-crd/capability-bindings.yaml.
On a cache miss the source issues
GET {AUTHZ_SAR_API_URL}/apis/resourcemanager.miloapis.com/v1alpha1/projects/{project}/control-plane
/apis/capabilities.assistant.miloapis.com/v1alpha1/capabilitybindings
with the assistant's own client certificate. There is no namespaces/ segment —
the kind is cluster-scoped, and the project in the prefix is the only thing that
scopes the read.
The cache is the reason this source exists rather than being a third way to say the same thing:
- 60 seconds, per project, matching the SAR cache's TTL. The config plane comes off the turn path for every request but the first of each window. The cost is honest: a revoked entitlement lingers for up to the TTL, which is the same window the platform already accepts for a revoked authorization.
- Serve stale on failure. When a refresh LIST fails and a previous good answer for that project is in hand, the assistant serves the stale answer rather than nothing. Without a cache, a provider outage and "entitled to nothing" are the same user experience: built-ins only, no signal, and the only evidence a warn line in the assistant's own logs.
- Never cache an empty result. An empty LIST is what a project with no bindings returns and what a project returns in the window between the catalog creating its first binding and that write landing. Caching it would pin a newly-entitled project to a built-ins-only assistant for a TTL, for no reason the user can see. The cache therefore only ever holds a positive entitlement set.
- Bounded, with the same eviction policy as the SAR allow-cache: expired entries swept first, then one live entry.
The degradation contract is the one the other sources already have — transport
error, non-2xx, or undecodable body is logged and falls back — with the fallback
improved from "nothing" to "the last good answer". Staleness is invisible in the
product by design; its audience is the operator and its channel is
assistant_capability_fetch_total{outcome="stale_served"}.
The assistant is the sole writer of status on these objects, and the
producer must not write it — two writers on one status block is a hot loop. This
is what the CRD buys that neither the fixture nor the HTTP source can: today a
provider whose MCP endpoint is unreachable learns nothing, because the failure is
a log line in someone else's service. As a condition it is a
kubectl describe capabilitybinding away for the team that owns the endpoint.
| Type | True |
False |
|---|---|---|
Accepted |
The spec parsed and validated; the binding is eligible to compose. | Validation failed. The message is the same path-qualified error the assistant would otherwise only log (spec.skills[0].source: required). Reasons: Validated / ValidationFailed. |
Composed |
The last turn that consulted this binding could use everything it declared. | An MCP endpoint outside CAPABILITY_MCP_ENDPOINT_HOSTS (named, with "was not dialed"); a connect failure or timeout; a tools/list that failed; a declared tool the server does not offer; a name already won by another binding; or total knowledge loss. Reasons: Composed / CompositionDegraded. |
Two things are deliberately not Composed=False: a knowledge source that
fails while its siblings succeed (including byte-cap truncation — the knowledge
arrived, just less of it), and a skill body that fails to fetch, which happens
lazily inside a turn and has no compose-time verdict at all. Each leaves a
working binding and each would flap per turn, and a flapping condition is a
per-turn control-plane write.
Writes are coalesced and rate-limited accordingly: only on a transition of
(type, status, reason, observed generation), never from a request goroutine, onto
a bounded queue drained by one worker that drops on overflow. A status update
that is lost is cosmetic; a status update that blocks a chat turn is an outage.
The patch is a merge-patch against the status subresource and therefore
carries the full condition set the writer knows — merge patch replaces an
array wholesale — so two replicas can transiently clobber each other until each
one's next transition. There is deliberately no conflict-retry loop; the next
observation is a better retry than an immediate one.
The service catalog's projection controller materializes one binding per (project, entitled service). Three obligations are not expressible in the schema and are load-bearing:
- Rewrite
mcpServers[].endpointto the AI gateway's MCPRoute URL, never the provider's own address. A raw endpoint loses the gateway-side copy of the tool allow-list, goes unmetered, and receives no caller identity. The assistant does not trust this: an endpoint outsideCAPABILITY_MCP_ENDPOINT_HOSTSis refused at the dial site and reported asComposed=False(see configuration.md). The check is the enforcement; the condition is how the catalog team finds out. - Delete the binding when an entitlement is revoked, rather than merely ceasing to serve it. A deleted binding stops composing after one cache TTL; an abandoned one composes indefinitely.
- Read the conditions. A projection the assistant rejects should be visible on the producing side too, which is the entire point of writing them.
config/overlays/dev-crd seeds bindings directly and exercises the object model,
the conversion, the cache, and the endpoint guard. It does not exercise the
production topology: kind runs one apiserver with no project router, so the
control-plane path collapses onto the same server, the grant is an ordinary
ClusterRole beside the pod rather than root RBAC at Milo, and the credential is a
service-account token that Milo itself would reject. Per-project isolation in
particular is provided by nothing in kind and is satisfied trivially there — see
test/e2e/README.md for why no e2e asserts it.
{ "kind": "AgentBinding", "metadata": { "name": "streamco-binding", "namespace": "demo-project" }, "spec": { "serviceRef": { "name": "streamco" }, "serviceName": "streaming.streamco.example", // used as the tool-invocation meter dimension "reportingProject": "streamco-platform", // where this service's capability-gap reports land "knowledge": { "sources": [{ "type": "LLMDocs", "url": "https://…/llms-full.txt" }], "concepts": [{ "gvk": { "group": "…", "kind": "Stream" }, "summary": "…" }] }, "tools": { "mcpServers": [{ "name": "streamco", "endpoint": "http://provider/mcp", "toolSelector": { "include": ["streams_list", "pipeline_diagnose"] } }] }, "skills": [{ "name": "lag-triage", "description": "Step-by-step procedure for triaging pipeline consumer lag", "source": "http://provider/runbooks/lag.md" // body fetched on demand }] } }