Capability's costume, standardised
My first essay argued that a capability should be the primary artifact of a service, and that everything a caller touches is a costume cut from it. The second essay argued that a distributed estate needs a list of those capabilities for every service, and that the list is only worth having if it cannot drift.
This essay standardises the costume. Today every capability wears one of its own making. A route, a verb, a status mapping, a body and a documentation page, each chosen by whoever built it, and every caller rebuilds the capability from its clothes. I have done that for most of my career. So will every agent pointed at an API, and an agent given forty conventions will invent the forty-first. The shipping container did not standardise the goods. It standardised the box, and everything that handled boxes was built once. A box with its own corner fittings stops the crane.
So this essay proposes one costume per transport, worn the same way by every capability in the estate, cut from a standard entry and a standard payload. Once the costume is standard it can no longer pretend to be the capability it dresses. What follows is a proposal. No estate I know of works this way today, though every part of it can be built. Rules use MUST, MUST NOT, SHOULD and MAY in the sense of RFC 2119.
Two services, two clocks#
The example is the one from the earlier essays. A bank’s reporting context has a statement_service. One of its capabilities, generate_statement, starts a run that generates a statement for an account over a period, in a chosen format, delivered by email or post. The billing_service invokes it.
Each service is built and deployed by its own team, on its own days. Beside them sits one central store.
Central store: The one place that keeps the status quo between services that never deploy together. It offers three operations. Capability registration and decommission, by which a service’s deploy registers every capability it exposes in one go and the store versions each transport, or removes them when the service is retired. Capability listing, by which a build reads the entries it will generate clients from. Dependency registration and decommission, by which a caller’s deploy records the capabilities it built against and over which transport, or removes that record when the deployment is removed.
The statement_service’s build derives an entry for each capability it exposes, by a process this essay takes as given, and its deploy registers them. The billing_service’s build lists the statement_service’s capabilities and generates its client from them. Listing is where an agent earns its place. It reads the intent of every entry, picks the capabilities to depend on, and has nothing to invent. The billing_service’s deploy then registers the dependency. Dependency registration fails when the environment serves nothing compatible with what the build listed. Once a dependency is registered, a capability registration that would break it fails, and the deployment with it. At runtime the billing_service invokes the statement_service directly. The store is never in the path of an invocation.
The entry#
The entry is what a caller reads before it invokes anything. Each field is there because the entry is unusable without it.
A name lets a caller ask for something, here generate_statement.
Name: What the capability is called within its service. It MUST NOT contain a dot.
A name means little until someone answers for it. The team behind the owning context is who a caller goes to when the capability misbehaves, and who reviews every change to it. Here it is reporting.
Owner: The most specific bounded context that performs the capability. It MUST NOT contain a dot.
Two contexts will pick the same verb, so a caller needs a name that is unique across the estate, here reporting.generate_statement.
Full name: The owner and the name joined by a dot. It identifies the capability in the entry and to every caller.
Failures are the field most estates never write down, and their absence is what reaches the customer. A failure’s name tells the caller what happened. What to do next is the caller’s decision. Here the failures are account_closed and period_invalid. Every capability also sits behind a boundary, and the boundary adds failures of its own that no entry lists.
Failures: The closed set of failures that belong to the capability. Each is a name and carries no data. The five boundary failures are implied for every capability and MUST NOT be listed.
Boundary failures:
no_replywhen nothing comes back by the caller’s deadline.unavailablewhen the other side cannot answer.rejectedwhen the receiver cannot accept what the caller sent.unauthenticatedwhen the credential is missing or cannot be verified.access_deniedwhen the caller is known and is not allowed. Each tells the caller something different to do next.
Every other field can be derived. One has to be written by a person, and it is the one an agent reads when choosing between capabilities.
Intent: One line stating what the capability is for and when to use it. The build MUST NOT generate it, and MUST refuse an entry without one.
A caller needs the transports that serve the capability and where to reach each. The build knows which transports the service has receivers for. Only the deploy knows the addresses, because one build runs in several environments.
[
{ "kind": "rest", "address": "https://statement-service.bank.internal" },
{ "kind": "grpc", "address": "statement-service.bank.internal:443" },
{ "kind": "messaging", "address": "amqps://broker.bank.internal", "queue": "statement_service.requests" }
]
The endpoint is never listed, because the costume fixes it. The environment’s DNS resolves each address to running instances. A capability MAY be served over several transports at once, and each carries its own version.
Transports: The transports that serve the capability, each with the address where it answers, and for messaging the queue. The kinds MUST be written at build time. The addresses MUST be added at deploy, and MUST NOT carry a context, because contexts are regrouped long after a service is deployed.
A caller can now find the capability and reach it. To send it anything, the entry needs two more fields.
Inputs: The JSON Schema of what the capability takes.
Output: The JSON Schema of what the capability returns when it succeeds.
Two more are needed once an estate has more than a handful of services.
A context has no existence of its own, so who may invoke a capability is declared on the capability, the way a visibility modifier sits on each member of a package. It is enforced at build time. A caller’s build MUST NOT get clients for capabilities it may not see, and an invocation is never checked against it.
Visibility: Who may invoke the capability.
internalallows only services in the owner’s context.externalallows any service in the organisation. Every entry MUST declare one.
A caller needs to tell whether what it built against is what is being served. A caller builds against a capability over one transport, so the version belongs to that pair, and a capability served over three transports has three versions. Neither service handles them. The central store assigns each at registration, by comparing the capability over that transport with the one it replaces in that environment, and publishes it when the deployment takes all the traffic. The comparison covers the promise, which is the inputs, output, failures and visibility, and the transport’s address and queue. The owner and the intent are outside it.
What counts as breaking follows from one test, whether every caller built against the earlier entry still works. A new optional input, a new output field and wider visibility are additive. A new required input, a removed or renamed field, a narrowed input, a new value in an output’s enum, a new failure, narrower visibility and a changed address are breaking. Adding a transport starts a version of its own, and removing one is breaking for every caller registered on it. Callers MUST ignore output fields they do not know.
A breaking change MUST be refused while any deployment is registered against the capability over that transport. A promise that has to change incompatibly while it has callers becomes a new capability under a new name, served beside the old one until nothing is registered against the old. An address moves the same way, as a new transport entry beside the old one. That makes a new major rare, and it is intended.
Version: Two numbers, major and minor, for each transport the capability is served over. A breaking change moves the major, an additive change moves the minor, and a change no caller can see moves neither. The central store MUST assign it, and MUST publish it only when the deployment takes all the traffic.
Here is the entry as the store lists it, with the addresses the deploy added and the versions the store assigned.
{
"$id": "urn:service:statement_service",
"capabilities": [
{
"owner": "reporting",
"visibility": "external",
"name": "generate_statement",
"intent": "Starts a statement run for an account. Poll the run for its status.",
"inputs": {
"type": "object",
"properties": {
"account": { "$ref": "#/shapes/AccountId" },
"period": { "$ref": "#/shapes/Period" },
"format": { "enum": ["pdf", "csv"] },
"delivery": { "enum": ["email", "post"] }
},
"required": ["account", "period", "format", "delivery"]
},
"output": { "$ref": "#/shapes/Run" },
"failures": ["account_closed", "period_invalid"],
"transports": [
{ "kind": "rest", "address": "https://statement-service.bank.internal", "version": "2.3" },
{ "kind": "grpc", "address": "statement-service.bank.internal:443", "version": "2.1" },
{ "kind": "messaging", "address": "amqps://broker.bank.internal", "queue": "statement_service.requests", "version": "1.0" }
]
}
],
"shapes": {
"AccountId": { "type": "string" },
"Period": {
"type": "object",
"properties": {
"from": { "type": "string", "format": "date" },
"to": { "type": "string", "format": "date" }
},
"required": ["from", "to"]
},
"Run": {
"type": "object",
"properties": {
"id": { "type": "string" },
"status": { "enum": ["queued", "running", "delivered"] }
},
"required": ["id", "status"]
}
}
}
The shapes are the service’s own, defined once under shapes and named by the owning team. A shape only one capability uses, like format, is written in place. An entry MUST NOT refer to a shape defined by another service. The document carries an $id, so every $ref resolves within it wherever it is fetched from. Nine fields, and every one is either something a caller must know or something an invocation must carry.
The invocation#
An invocation has two layers. The payload is what the entry declares. The envelope is what the costume wraps around it.
Payload: The inputs on the way in, and on the way back the outcome, which is the output or a problem naming one declared failure. It is JSON on every transport, and it MUST be the same on every transport, or the transport becomes part of the promise.
Manifest: The capability being invoked and the identity of this invocation. The costume carries it in an envelope of its own, and the reply’s envelope carries the identity back where the transport does not pair a reply with its request.
Identity: An opaque string chosen by the caller, unique among its invocations of the capability, and the same on every retry.
Credential: What the estate’s identity provider issues to a caller to prove which service it is. The envelope carries it, and the receiver establishes the caller from it before anything else.
The identity lets asking twice be recognised as asking once, over a transport that loses replies or a queue that delivers twice. Two callers can choose the same identity, so a callee MUST keep each caller’s identities apart, by the caller its credential established.
A failure comes back as a problem, and it looks the same on every transport.
Problem: A JSON object with three members.
typeisurn:capability:<name>:<failure>, and a caller MUST branch on it.titleanddetailare for people, and a caller MUST NOT parse them. No other members are added.
{
"type": "urn:capability:generate_statement:account_closed",
"title": "Account closed",
"detail": "The account was closed on 31 August 2026."
}
Nothing else in the envelope is part of the promise. A deadline belongs to the caller, who decides how long to wait. Trace context belongs to the estate’s observability and rides beside the envelope, the way it does today.
The costumes#
Each transport gets exactly one costume, and every capability wears it. The standard holds only if every caller and every receiver on a transport behaves the same way, across every service in the estate. One receiver that opens its envelope differently is a costume of its own, and every caller then has to know which service it is talking to, which is what the standard exists to remove. The costume for a transport MUST be defined once for the estate, and every caller and every receiver MUST follow it.
A service SHOULD have one receiver per transport, not one per capability. A receiver opens the envelope, establishes the caller from its credential, asks the estate whether that caller may invoke the capability, resolves the capability by its name, decodes the payload against the entry, invokes the function and wraps the outcome in a reply envelope.
Whether a caller may invoke a capability is the estate’s decision, made at the boundary. A credential that is missing or cannot be verified is unauthenticated, and a caller the estate refuses is access_denied. The capability never makes that decision, and a refusal never reaches the payload.
An invocation MUST complete on the transport it started on. Where the transport already targets one service, the envelope carries the bare name and the receiver completes the full name from its own entries. Where the infrastructure is shared, it carries the full name. A receiver MUST NOT refuse an invocation because the context in its full name has since been regrouped.
The receiver ends at a plain function with the capability’s signature. Whichever transport delivered the invocation, it arrives here, and the person who owns the capability writes this function and nothing else.
generate_statement(account, period, format, delivery) -> Run
fails: account_closed, period_invalid
REST#
The manifest is the address, one route template for every capability.
PUT /capabilities/{name}/executions/{id}
{name} is the bare name and {id} is the identity. The credential rides in the Authorization header or the client certificate. The request body is the inputs and the reply body is the outcome. The reply envelope is empty, because HTTP pairs the reply with its request.
The verb is PUT because RFC 9110 defines it as idempotent, so a retry is an identical request to the same address. That is an enabler, and the handler decides. An invocation creates an execution, which is the callee’s resource. A capability whose effects cannot safely happen twice MUST remember each execution by its address and its caller, answer it once, refuse the same address with different inputs, and keep the execution for at least as long as the estate lets a caller retry. GET on the address MUST return the outcome of a remembered execution, so a caller that lost the reply fetches it instead of asking again. A capability that is harmless to repeat MAY remember nothing. Reads MUST go through the same PUT, because a second costume for reads is what the standard exists to remove.
The receiver returns 200 with the outcome, 400 for rejected with a reason in the body for people, 401 for unauthenticated and 403 for access_denied. Everything else is unavailable, and no reply by the caller’s deadline is no_reply. Declared failures MUST NOT become status codes.
gRPC#
One service with one method, Invoke. The request message carries the manifest, the bare name and the identity, wrapped around the inputs as JSON. The credential rides in the request metadata or the channel. The reply message is the outcome as JSON, and its envelope is empty, because gRPC pairs the reply with its request as HTTP does. There is one method rather than one per capability, because a method per capability would make the proto the document that defines the capability. Only the entry does.
Retries carry the same identity and the callee remembers executions by capability, identity and caller, as it does over REST. The receiver returns OK with the outcome, INVALID_ARGUMENT for rejected with a reason in the status message for people, UNAUTHENTICATED and PERMISSION_DENIED for the two refusals they name. Everything else is unavailable, and DEADLINE_EXCEEDED is no_reply. Declared failures MUST NOT become status codes.
Messaging#
One request queue per service, named in the entry with its broker. The envelope is the message headers. They carry four things. The identity. A reply-to naming the caller’s reply queue. A credential the receiver can verify, because the broker authenticates the producer to itself and to nobody else. And the full name, because on a shared broker the queue is only a convention. The body is the inputs. The reply’s headers carry the identity and its body carries the outcome.
The caller MUST own its reply queue, fixed when the caller deploys and never created per invocation. That queue is shared by every instance of the caller, so the instance that receives a reply is rarely the one that asked. A caller MUST keep what it needs to act on a reply where every instance can reach it, keyed by the identity, before it sends the request.
A queue delivers at least once. The callee recognises a duplicate by its identity, and the caller discards a reply it has already matched. There is no status line, so rejected, unauthenticated and access_denied come back as a reply whose envelope names the failure, with a reason for people and no payload. A broker that refuses the message is unavailable. No reply by the caller’s deadline is no_reply. A message that expects no reply is a different act, and it should have a different name.
Left to the estate#
This proposal standardises three things, the entry, the payload and one costume per transport. Everything around them stays with what the estate already runs. Its identity provider says who a service is and what it may invoke. Its deployment tooling and DNS decide where a service answers. Its central store is the one component this proposal adds, because neither party in an invocation could do its job. A caller cannot see every other caller, and a callee cannot see who depends on it. How it is built is left open.
The limits#
How a build derives the entries it registers, from the code it compiles, is its own essay. This one starts where that entry exists.
Messaging can carry a reply that arrives tomorrow. What it cannot do alone is keep a caller’s place while it waits, and a caller that parks halfway through its work and resumes when the answer comes is durable execution, which is its own essay. A message can also outlive the deployment that sent it, and reach a callee or a caller that no longer has the code for it. Nothing here prevents that.
A stream is not a capability. A capability answers once, and a stream never does. The subscription can be a capability, asked once and answered once with a handle. What flows after that is not an invocation.
Nine fields, two layers, one costume#
Nine fields per entry. A payload of inputs in and outcome back, the same on every transport, inside an envelope each costume owns. One costume per transport, worn the same way by every capability, with a receiver behind it that ends at one plain function. And a central store beside them, written by every deploy, read by every build, and never in the path of an invocation.
The costume is declared in the entry, in the open. It is simply not the capability, and now there is a place that says so.