ADR 0159: a tool call names the machine, every answer says which answered, and a holder's runtime serves its seat's verbs #248

Merged
mesh-admin merged 1 commits from feat/a-tool-call-names-the-machine into main 2026-10-01 12:01:17 +00:00
5 changed files with 168 additions and 2 deletions
@@ -0,0 +1,99 @@
---
topic: the mesh
status: accepted
date: 2026-10-01
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
---
# 159. A tool call names the machine it is for, every answer says which machine answered, and a holder's runtime serves its seat's verbs
## Context
[ADR 0152](0152-the-operators-surface-is-a-module-the-console.md) made a module's tools subjects on
the bus and the console the place a person reaches them. [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)
made the mesh's own verbs the controller seat's tools, served by the controller. Design 33 said what
a seat's tools are, that holding a seat means serving them, and that a node-scoped seat's verb
carries the machine.
What was built stopped short in two places ([issue 182](../04-ISSUES/182-a-tool-call-reaches-whichever-instance-answers-first/00-report.md)).
A module's tools were one subject per module in one queue group, so with the database engine on two
machines a call reached whichever instance answered first, unnamed, and nobody could ask one machine's.
And no module served the verbs of a seat it held: the runtime did not know which seats its module
claimed, and no seat but the controller's declared verbs. The operator named it: a tool call must be
able to say *the store on the control node*, and the engine holding the store seat must serve the
store's tools as well as its own.
## Considered Options
1. **Leave the queue group and ask the controller which machine answered.** Nothing changes on the
bus; a caller cannot choose, only learn afterwards. Useless for the question that was asked.
2. **A subject per machine instead of one per module.** Every call names a machine; a stateless
module on three machines loses the one-of-them answer a queue group gives for free, and every
caller has to know where things run.
3. **Both subjects, and the machine in every answer.** An instance serves its module's subject in the
queue group as before, and the same subject with its machine as the last token. A caller that
names no machine gets one instance and is told which; a caller that names one gets that one. The
grant for a tool covers both. And a holder's runtime serves its seat's verbs by the same means,
from what the credential tells it.
## Decision
**Option 3.**
- **Two subjects per tool, one default.** `mesh.mod.<module>.tool.<tool>` in the queue group, and
`mesh.mod.<module>.tool.<tool>.<node>` served by the instance on that machine alone. In the
caller's words, `<module>.<tool>@<node>`. A runtime that does not know its machine serves only the
first, which is what it always did.
- **Every answer says which machine answered.** The reply carries the node; the console appends
*answered by <node>* as its own line after the module's unshaped answer, and `mesh call` prints it.
An answer from a module on several machines is never an answer from nowhere.
- **The console offers the machine on every module tool** as an optional `node` argument, lists it,
strips it into the subject and never passes it to the module. A seat's verb takes none: the seat's
scope decides where it is served.
- **The grant covers both subjects.** `invokes: [<module>.<tool>]` permits the plain subject and the
machine-addressed one; `*` already permitted everything beneath `tool`.
- **A holder's runtime serves its seat's verbs.** The broker credential the mesh writes names the
seats the module claims and, for each, its scope and the verbs the seat promises. The runtime
serves each verb with the module's tool of the same name on the seat's own subject — flat for a
mesh seat, with the machine for a node-scoped one — and the bus admits that subscription only
where the module holds the seat, because the holder's grant is composed from the holding. A
claimant that does not hold the seat here is refused the subscription and serves nothing. A
claimant missing a tool a seat promises is already refused at registration (design 33 §3).
- **The store seat's first verbs**, so the operator's question has an answer: `databases`, every
database the store holds with its owner and size, and `query`, one read-only statement against one
database. The database engine serves both as tools of those names and lists them in its definition.
Which verbs a seat serves is a decision per seat and binds every holder; these two are the smallest
set that makes the store askable.
## Consequences
- *List the databases of the store on the control node* is `mesh-store.databases` through the seat,
answered by its holder wherever it sits, or `postgres.databases@novox` through the module on one
named machine. Both say who answered.
- Every module's runtime serves one more subscription per tool and, for a claimant, one per promised
verb. No manifest changes for the per-machine half; the seat half needs each holder's definition to
list the seat's verbs among its tools, which registration already demands.
- The runtime change reaches a module when the module is rebuilt on the new runtime image; until
then that module answers only on its plain subject, and a call naming its machine is refused as
unserved, in words that say so.
- The credential gains `claims`; a module issued before this carries none and serves no seat verb
until it is issued again. `rollout mint` for the holders is the one-time cost.
## How this is checked
| Rule | Checked by |
|---|---|
| A call naming a machine reaches that machine's instance; an unnamed call reaches one and says which | mesh-tools, against a real bus: a module on two machines |
| A claimant serves a seat's verb on the seat's subject, and the answer names the machine | the same test |
| The console lists `node` on a module's tool and not on a seat's verb, and the answer carries *answered by* | mesh-tools, the MCP conformance test |
| The grant for a tool covers the plain and the machine-addressed subject | `TestInvokingAToolMayAddressTheMachineToo` (controller) |
| Live: the store's databases listed from the control node by name through the console, and through the store seat | done by hand after the roll-out and the catalogue's step |
## References
- [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md) — extended: the surface carries the machine
- [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md) — the seat half, now for every holder
- [Design 33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §3, §4; [Design 34 — The console](../03-DESIGN/01-to-be/34-the-console.md) §3
- [Issue 182](../04-ISSUES/182-a-tool-call-reaches-whichever-instance-answers-first/00-report.md)
+1
View File
@@ -172,6 +172,7 @@ python3 00-META/checks/index.py fail if stale
- **0156** — [An artifact is what a build produces, the artifact store serves every kind, and its seat is named for its scope](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)
- **0157** — [A build says what it does on the bus, as it happens](0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md)
- **0158** — [A provider with one credential shares it with every consumer, and the vault remakes it for all of them at once](0158-a-provider-with-one-credential-shares-it-with-every-consumer.md)
- **0159** — [A tool call names the machine it is for, every answer says which machine answered, and a holder's runtime serves its seat's verbs](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
### Its tiers, from the bottom up
@@ -2,8 +2,9 @@
layer: to-be
status: implemented
code: [mesh-controller, mesh-tools]
updated: 2026-09-30
updated: 2026-10-01
decisions:
- 02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md
- 02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md
- 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
@@ -79,6 +80,14 @@ machine's holder and the holders' queue group would hand the call to whichever a
node-scoped seat's tool therefore carries the node it is asked of. Nothing about a mesh-scoped seat
changes.
*2026-10-01 ([ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)):*
the same shape now serves a **module's** tool on several machines, which had the queue-group fault
this section describes for seats: each instance also serves `mesh.mod.<module>.tool.<tool>.<node>`,
a caller writes `<module>.<tool>@<node>`, and every answer names the machine that gave it. And §3 is
built for every holder, not only the controller: the credential names the seats a module claims and
their verbs, the runtime serves each with the tool of the same name on the seat's subject, and the
bus admits it only where the module holds the seat.
## 5. Discovery
**What a role answers is a read.** The seats and their protocols are records, so the list is a query
+8 -1
View File
@@ -2,8 +2,9 @@
layer: to-be
status: implemented
code: [mesh-catalog, mesh-tools, mesh-controller]
updated: 2026-09-30
updated: 2026-10-01
decisions:
- 02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md
- 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
- 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
- 02-DECISIONS/0095-the-control-plane-is-the-way-to-ask-a-module.md
@@ -64,6 +65,12 @@ once a request nothing serves, so a module that is not running costs nothing and
as not answering, rather than silently absent — *silence and success must never look alike*. The list is
kept for a short while and refreshed, so an agent asking on every turn does not fan out on every turn.
**Every module tool takes the machine to ask** (*2026-10-01*, [ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)):
an optional `node` the console lists on each one, puts into the subject and never hands to the module,
for a module that runs on several machines; without it whichever instance answers first does, and the
console appends *answered by <machine>* to every answer. A seat's verb takes none; the seat's scope
decides.
**A tool that was not listed can still be called.** Listing is discovery; calling is the grant. An agent
that knows a tool's name asks for it by `<module>.<tool>` and the module answers or the bus says why not.
@@ -0,0 +1,50 @@
---
status: resolved
opened: 2026-10-01
located-in: [mesh-tools src/broker-nats.ts (one subject, one queue group per module), mesh-tools src/mcp.ts (no way to name a machine), mesh-tools src/runtime.ts (a claimed seat's verbs served by nobody), mesh-controller internal/broker/nats.go (the grant for a tool named one subject)]
fixed-by: mesh-tools PR (feat/a-tool-call-names-the-machine) and mesh-controller PR (same branch) — see ADR 0159; the store seat's verbs and postgres's tools follow in the catalogue
amended-design: [03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md, 03-DESIGN/01-to-be/34-the-console.md]
---
# 182 — A tool call reaches whichever instance answers first, and a claimed seat's verbs are served by nobody
## What was observed
Asked how to list the databases of the store on one machine, the mesh had no answer. A module's tools
are served on one subject per module, `mesh.mod.<module>.tool.<name>`, and every instance of the
module joins one queue group on it, so a call to the database engine's tool while it runs on two
machines reaches whichever answered first, and the answer does not say which. There is no way to ask
the instance on one machine. The console lists the tool once and offers no machine.
And the seat half was missing too. Design 33 §3 says holding a seat means serving its tools, and
ADR 0154 built that for the controller's own seat alone. A module that holds a seat — the database
engine on the control node holding `mesh-store` — served none of the seat's verbs, because no seat
but the controller's declares any and no runtime knew which seats its module claimed.
## Why this is here
Both are the same omission: the tool surface was built as if every module ran on one machine and
held no seat. A queue group is the right default for a stateless module answering anywhere, and the
wrong only choice for a module whose instances are different things — two stores with different
databases. The architecture had the distinction: a module is a thing that runs on machines, a seat is
a role one of them holds. The tool surface did not carry it.
## Resolved, 2026-10-01
[ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md).
Every instance serves its module's subject twice: in the queue group as before, and with its own
machine as the subject's last token. `<module>.<tool>@<node>` reaches one machine's instance; the
console lists `node` on every module tool and puts it in the subject, never in the module's
arguments; every answer carries the machine that gave it, and the console appends it as its own line.
The grant for a tool covers both subjects.
A holder's runtime serves its seat's verbs: the credential the mesh writes names the seats the module
claims and the verbs each promises, the runtime serves each verb with the module's tool of the same
name on the seat's own subject, flat for a mesh seat and with the machine for a node-scoped one, and
the bus admits the subscription only where the module holds the seat. The store seat's first verbs
and the database engine's tools for them are the catalogue's next step, recorded in the decision.
*How it is checked:* against a real bus, a module on two machines answers each by name and says who
answered when unnamed, and a claimant answers a seat's verb on the seat's subject; the console lists
`node` on a module's tool and not on a seat's; the grant for a tool covers both subjects; and, live,
the store's databases listed from one named machine through the console.