Files
hq/02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
T
jschoubben d57196102d ADR 0132: a seat carries the tools its holder must serve
A role's tools belong to the role, not to whichever module holds it today: the seat declares them
with their schemas, serving them is a condition of occupying the seat, and what the mesh can do
becomes a read of its own records rather than a question nothing answers. A module keeps its own
tools — the same module may run without the seat, and then only its own name is true.

Design 33 follows: the three families, addressing a node-scoped seat, discovery, and what serves
this to an agent.
2026-09-28 10:16:59 +02:00

9.9 KiB

topic, status, date, deciders, reconstructed, extends
topic status date deciders reconstructed extends
the mesh accepted 2026-09-28 jochen false 02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md

132. A seat carries the tools its holder must serve

Context

ADR 0129 gave a seat the protocol of its role in three parts: the work it accepts, the events it emits, and the verbs it serves — request and reply, awaited. The bus already derives authority from all three: a holder subscribes mesh.seat.<seat>.tool.<verb>, and a module that uses the seat may publish it and nothing else.

The serving third has never been used. The mesh defines 14 seats, 8 mesh-scoped and 6 node-scoped. Exactly one carries a protocol at all — the build machine, which accepts build and emits built. Not one seat declares a single verb it serves. The mechanism is built, enforced, and empty.

Meanwhile every tool on the mesh is addressed to a module. Of 72 modules in the catalogue, 45 serve tools, about 203 of them, each on mesh.mod.<module>.tool.<name>. So a caller binds to the module that happens to hold a role rather than to the role, and replacing that module breaks every caller — which is the thing seats exist to prevent everywhere else.

Nothing can say what tools exist. Measured on 2026-09-28, with the bus carrying the whole mesh: a workstation client holding an operator credential connected, the bus accepted the account, and mesh call gitea.gitea_list_repos answered with real repositories. The same client's mesh tools found nothing, because it asks mesh-catalog.catalog_tools and no module serves that: the catalogue serves catalog_modules, catalog_module, catalog_provides, catalog_dependents and catalog_stale. An agent can therefore call any tool it already knows the name of and discover none. MCP's tools/list is that same question, so the MCP surface is a working transport over an empty catalogue.

And there is nowhere for a tool's definition to live. A manifest has a tools field: 0 of the 45 modules that serve tools fill it. That is not neglect, it is the arrangement failing — the field was the bus grant's source for what a module may subscribe, and because nothing filled it every module that served a tool was refused its own subscription on the new bus, live, until the grant was changed to the module's own namespace. Today a tool's name, description and argument schema exist only in the module's code.

Two facts about the machinery matter for what follows. A seat's protocol is not in the store: the seat rows lack the ADR 0129 columns, so the protocol comes from compiled defaults and is merged in when a row is read. And seatSubject is flat — mesh.seat.<seat>.<kind>.<verb> with no node in it — so a node-scoped seat's tool call would reach every node's holder at once, and the holders' queue group would hand it to whichever answered first.

Decision

A seat's protocol carries its tools in full: the verb, what it does, and the schema of its arguments and of its answer. The seat is the definition of the role's interface; the holder is an implementation of it.

Serving the seat's tools is a condition of holding the seat. A module that does not serve every verb the seat declares may not occupy it. This is checked where the other conditions of holding are checked — registration and handover — and refused by naming the verbs that are missing.

A role's tools are addressed to the role. mesh.seat.<seat>.tool.<verb> mesh-wide. A node-scoped seat carries the node in the address, because one subject reaching six machines' holders is not an address, and the queue group that made it look like one would silently pick a winner.

A module keeps its own tools, and both exist. gitea_list_repos stays, because gitea can run without holding the git seat — a second forge, an instance kept for one purpose. The module's name answers this gitea; the seat's verb answers whoever is the forge. Which of the two a caller wants is a decision in the running session, not one the mesh makes for it.

What answers "what tools exist" follows where the definition lives. A seat's tools are read from the mesh's own records. A module's own tools are answered by the module, from the code that defines them. Discovery is therefore a read for the durable half and a question to the running mesh for the free half.

A seat's tools are an interface, and change like one. Additive within a version; a change that would break a caller takes the version token the subject already has room for (design 29 §8), and the two run side by side until nothing is bound to the old one.

The mesh's own verbs are the mesh-controller seat's tools. status, push, build, assign and the rest are a role's interface, not a container's, and the audit point ADR 0095 asks for is the seat's holder.

Options considered

  1. The manifest declares each module's tools. Rejected. The list is then written twice — in the manifest and in the code — and a schema in a manifest goes stale silently, which is the worst kind of wrong for something an agent reads to decide what to call. It is also the arrangement that has already failed once: the field exists, 0 of 45 modules fill it, and the grant that depended on it refused every tool subscription on the mesh.
  2. Every runtime answers an introspection call, and something aggregates them. Rejected as the shape for a role's tools, kept for a module's own. An aggregator needs permission to publish into every module's namespace, which is a widening the mesh otherwise gives only to the control plane; and the answer is only as available as the modules are, so a mesh whose catalogue cannot say what a role answers while its holder is down cannot plan against it.
  3. The control plane answers everything. Rejected. It puts a tool surface on the control plane for tools it does not implement, and makes discovery depend on the one component that must stay answerable while it is itself being replaced. The mesh's own verbs are its to answer, and it answers them as the holder of a seat.
  4. Seats only; no module tools. Rejected. Most modules hold no seat, and inventing a seat per module to give its tools a home would dilute what a seat is: one holder of a role the mesh needs exactly one of.

Consequences

One capability can have two names, deliberately. A forge that holds the git seat answers both mesh.seat.git.tool.list_repos and mesh.mod.gitea.tool.gitea_list_repos. This is the one place the mesh accepts two names for one thing, because they are answers to different questions and the second one survives the module not holding the seat. The glossary rule stands everywhere else.

A seat becomes a contract to implement. Adding a verb to a seat is a change every holder must make, and a claim that was valid becomes invalid until it does. That is the point, and it is also the reason a seat's tools should be few and durable while a module's own stay free.

Three prerequisites, none of them in place. The seat's protocol must be in the store rather than in compiled defaults, or discovery reads a binary rather than the mesh. The protocol must become richer than a list of verbs, because a verb without a schema is not something an agent can call. And a node-scoped seat needs the node in its subject before any of its tools can exist.

Discovery becomes cheap for the half that matters. What roles the mesh has and what each answers is a query, with no fan-out and nothing to be up. An agent's authority can then be role-shaped — the forge's tools — rather than a list of module-specific names that changes when a module is replaced.

The MCP surface belongs inside the mesh. Once the tools are the mesh's own records, the thing that serves them to an agent is a module the mesh assigns to the machine where the agent sits, with a credential the mesh minted and authority derived from what it may call — not a program started by hand with a credential printed to a terminal.

How this is checked

  • Holding is refused without the verbs. The condition sits with the other conditions of holding a seat, so registration and a handover both refuse a module that does not serve what the seat declares, and the refusal names the missing verbs. A test per condition, as the other seat conditions have.
  • The grant is derived from the seat, and already is. A holder's subscription and a user's publish come from the seat's protocol, so a verb nobody declared is a subject nobody may use, and a verb the seat declares reaches exactly its holder. The golden composition of the bus's user list is the test that keeps it honest.
  • Discovery is a read, and is tested as one. What the mesh answers for a seat's tools equals what the seat's records declare — no call to a module in the path, so the test needs no running module.
  • A node-scoped seat's subject carries its node, checked by the same test that checks the subject table: two nodes holding one node-scoped seat derive two addresses.

References

  • ADR 0129 — the protocol this widens
  • ADR 0121 — the role, its work and its events
  • ADR 0095 — a tool call passes one process where an audit belongs
  • ADR 0126 — an event is addressed to its emitter, for the same reason a role's verb is addressed to its role
  • 03-DESIGN/01-to-be/26-the-seats.md — how a seat is held and handed over
  • mesh-controller #116, #117, #118 — the grants as they now stand: a module serves its own namespace, the control plane may ask any tool
  • Measured 2026-09-28 on the live mesh: an operator credential calling a module's tool over the bus answers; tools/list finds nothing