From d57196102d8484d07bca0e5f1f9505aa8c7a745c Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 28 Sep 2026 10:16:59 +0200 Subject: [PATCH] ADR 0132: a seat carries the tools its holder must serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...carries-the-tools-its-holder-must-serve.md | 150 ++++++++++++++++++ 02-DECISIONS/README.md | 1 + .../01-to-be/33-the-tools-the-mesh-answers.md | 137 ++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md create mode 100644 03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md diff --git a/02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md b/02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md new file mode 100644 index 0000000..3400298 --- /dev/null +++ b/02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md @@ -0,0 +1,150 @@ +--- +topic: the mesh +status: accepted +date: 2026-09-28 +deciders: jochen +reconstructed: false +extends: 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](0129-a-seat-carries-the-protocol-of-its-role.md) 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..tool.`, 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..tool.`. 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...` 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..tool.` 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](0095-the-control-plane-is-the-way-to-ask-a-module.md) +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](0129-a-seat-carries-the-protocol-of-its-role.md) — the protocol this widens +- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — the role, its work and its events +- [ADR 0095](0095-the-control-plane-is-the-way-to-ask-a-module.md) — a tool call passes one process where an audit belongs +- [ADR 0126](0126-a-module-declares-its-own-seats.md) — 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`](../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 diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index fa74c90..d9ea7b9 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -140,6 +140,7 @@ python3 00-META/checks/index.py fail if stale - **0129** — [A seat carries the protocol of its role](0129-a-seat-carries-the-protocol-of-its-role.md) - **0130** — [The predecessor is ending, and its broker goes with it](0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md) - **0131** — [Everything on the mesh speaks to the broker seat, and AMQP is not a provision](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) +- **0132** — [A seat carries the tools its holder must serve](0132-a-seat-carries-the-tools-its-holder-must-serve.md) ### Its tiers, from the bottom up diff --git a/03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md b/03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md new file mode 100644 index 0000000..cdb2127 --- /dev/null +++ b/03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md @@ -0,0 +1,137 @@ +--- +layer: to-be +status: designed +code: [] +updated: 2026-09-28 +decisions: + - 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md + - 02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md + - 02-DECISIONS/0095-the-control-plane-is-the-way-to-ask-a-module.md +--- + +# 33 — The tools the mesh answers + +**An agent can call the mesh's tools and cannot find out what they are.** Both halves were measured +on the live mesh on 2026-09-28: a client holding an operator credential connected to the bus, asked a +module for its repositories and got them; the same client's request for the tool list found nothing +serving it. The transport works, the account model works, the adapter that speaks the agent protocol +works. What is missing is the mesh being able to say what it can do. + +This design is the answer to that question, and it has three families in it, because a tool belongs to +whoever is accountable for answering it. + +## 1. Three families, and why the split is not arbitrary + +| Family | Addressed to | Where the definition lives | Example | +|---|---|---|---| +| A **role's** tools | the seat: `mesh.seat..tool.` | the seat's protocol, in the mesh's records | ask *the forge* to list its repositories | +| A **module's** tools | the module: `mesh.mod..tool.` | that module's code | ask *this gitea* for `gitea_list_repos` | +| The **mesh's** own verbs | the `mesh-controller` seat | the seat's protocol, as above | `status`, `push`, `build`, `assign` | + +The split follows accountability. A role is something the mesh guarantees exactly one holder of, so +what the role answers is the mesh's to define and a holder's to implement +([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)). A module's +own tools are nobody's business but the module's, and their definitions live where they are +implemented, because a copy kept anywhere else drifts from the code that answers. + +The mesh's own verbs are the third family only in where they come from, not in kind: the control plane +holds a seat like anything else, and its tools are that seat's. This is what keeps them addressable +while the control plane is being replaced, which is the moment they are most needed. + +**Both names for one capability is deliberate and bounded to this.** A forge holding the `git` seat +answers the role's `list_repos` and its own `gitea_list_repos`, because the same module may run +without the seat — a second instance, kept for one purpose — and then only the second name is true. +The caller chooses which question it is asking. Nothing else in the mesh gets two names. + +## 2. What a seat's tool is + +A verb, what it does, and the schema of its arguments and its answer. A name alone is not callable by +something that has never seen the mesh before, which is the whole population this surface exists for. + +The protocol a seat carries today is three lists of bare verbs +([ADR 0129](../../02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md)), and it must widen to +carry the rest. Two constraints on that widening: + +- **It lives in the mesh's records, not in the control plane's binary.** Today a seat's protocol comes + from compiled defaults, merged in as a row is read, because the seat rows never gained the columns. + Discovery that reads a binary is discovery that disagrees with the mesh the moment the two are on + different versions. +- **The schema is stated in the form an agent protocol already uses**, so nothing translates between a + seat's idea of an argument and the caller's. A translation layer would be a second definition of + what a tool is. + +## 3. Holding a seat means serving its tools + +A module may not occupy a seat unless it serves every verb that seat declares. This joins the +conditions of holding that already exist — providing what the seat delivers, being assigned at the +seat's scope — and is refused the same way: at registration and at handover, naming the verbs that are +missing rather than the fact that something is. + +A module knows which seats it claims, so knowing which tools it must serve is not a discovery problem +for the module: the seat says, the module implements, and anything beyond that is its own. + +## 4. Addressing a node-scoped seat + +A seat's subject is flat today — `mesh.seat...` — which is correct for a seat the +mesh has one holder of and wrong for the six node-scoped seats, where one subject would reach every +machine's holder and the holders' queue group would hand the call to whichever answered first. A +node-scoped seat's tool therefore carries the node it is asked of. Nothing about a mesh-scoped seat +changes. + +## 5. Discovery + +**What a role answers is a read.** The seats and their protocols are records, so the list is a query +against the mesh's own store: no call to a module in the path, nothing that has to be running, and an +answer that stays true while a holder is restarting or being replaced. + +**What a module answers comes from the module.** Its definitions live in its code, so it is asked, and +the answer is as available as the module is — which is the right coupling for a tool that only exists +while that module does. + +A caller therefore gets one list assembled from two sources, and the difference is visible in it: a +role's tool names a seat, a module's names a module. An agent that wants to survive a holder being +replaced binds to the first. + +## 6. What serves this to an agent + +A module the mesh assigns to the machine where the agent runs, holding a credential the mesh minted, +with authority derived from what it may call — not a program started by hand with a credential printed +to a terminal. The adapter itself already exists and is thin by design; what changes is that it stops +being something a person carries and becomes something the mesh runs, on a node, like everything else. + +An agent's authority can then be role-shaped: *the forge's tools*, rather than a list of +module-specific names that changes the day the forge is replaced. + +## 7. Versioning + +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, and the two versions run side +by side until nothing is bound to the old one. + +## How it is checked + +- **A holder missing a verb cannot take the seat.** One test per condition of holding, as the existing + conditions have, and the live refusal names the verbs. +- **A verb nobody declared is a subject nobody may use.** The bus grants are derived from the seat's + protocol already, and the golden composition of the user list is what keeps that honest: a holder is + granted exactly the seat's verbs, a user of the seat exactly the publish side. +- **Discovery needs no running module.** The test for a role's tools reads records and asserts the + answer equals what the seats declare — if it needed a module up, it would not be a read. +- **Two nodes holding one node-scoped seat derive two addresses.** Checked by the same test as the rest + of the subject table. + +## What this does not settle + +- Which verbs each seat should serve. That is a decision per seat, and the reason to do it slowly: a + seat's tools bind every future holder. +- Whether a module's own tool definitions should also be recorded when a build resolves its manifest. + There is an argument for it — the mesh could then answer for a module that is down — and an argument + against, which is that a recorded copy of a live definition is a copy that can be wrong. + +## References + +- [ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md) — the decision this designs +- [ADR 0129](../../02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md) — a seat carries the protocol of its role +- [ADR 0095](../../02-DECISIONS/0095-the-control-plane-is-the-way-to-ask-a-module.md) — a tool call passes one process where an audit belongs +- [`26-the-seats.md`](26-the-seats.md) — what a seat is, how it is held and handed over +- [`25-the-bus-on-nats.md`](25-the-bus-on-nats.md) §7 — a person's account, their inbox, and the adapter -- 2.54.0